From 117623e80fd3f9ffd60769df6b01fa21cd2ebb27 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 23 May 2026 20:57:06 -0400 Subject: [PATCH 01/14] Implement subscriptions & APNs tokens epic (#379) [skip ci] Add curated CloudKitService support for the five CloudKit Web Services push-notification endpoints, plus MistDemo CLI, integration-phase, and web-server wiring. MistKit core (#49/#50/#51/#52/#53): - listSubscriptions / lookupSubscriptions / modifySubscriptions (+ create/ delete convenience) and createAPNsToken / registerAPNsToken - New domain types under Models/Subscriptions and Models/Tokens; errors route through CloudKitServiceError; CloudKitResponseType conformances per op - openapi.yaml: promote the records/query inline query shape into a shared named `Query` schema, referenced from both records/query and Subscription.query, so query subscriptions reuse the same query model; regenerated MistKitOpenAPI MistDemo: - Real list/lookup/create-token/register-token commands + new modify-subscriptions command; SubscriptionRoundtripPhase + TokenRoundtripPhase wired into the private pipeline - Web server: WebBackend methods + routes for subscriptions/* and tokens/*, replacing the 501 pending stubs; tokens.js feeds the minted token into register Native app left informational (per #52/#53). Adds unit + service + web-route tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/CreateTokenCommand.swift | 38 +++-- .../Commands/ListSubscriptionsCommand.swift | 18 +- .../Commands/LookupSubscriptionCommand.swift | 23 ++- .../Commands/ModifySubscriptionsCommand.swift | 104 ++++++++++++ .../Commands/RegisterTokenCommand.swift | 28 ++-- .../ModifySubscriptionsConfig.swift | 108 ++++++++++++ .../Configuration/RegisterTokenConfig.swift | 7 +- .../Errors/SubscriptionCommandError.swift | 56 +++++++ .../TokenCommandError.swift} | 32 ++-- .../Phases/SubscriptionRoundtripPhase.swift | 110 ++++++++++++ ...nPhase.swift => TokenRoundtripPhase.swift} | 28 +++- .../Tests/PrivateDatabaseTest.swift | 2 + .../Sources/MistDemoKit/MistDemoRunner.swift | 1 + .../MistDemoKit/Resources/js/subscriptions.js | 7 +- .../MistDemoKit/Resources/js/tokens.js | 12 +- .../MistDemoKit/Server/WebBackend.swift | 58 +++++++ .../Server/WebRequests+Subscriptions.swift | 122 ++++++++++++++ .../Server/WebRequests+Tokens.swift | 78 +++++++++ .../MistDemoKit/Server/WebResponse.swift | 25 +++ .../Server/WebServer+Pending.swift | 37 +---- .../Server/WebServer+Subscriptions.swift | 114 +++++++++++++ .../MistDemoKit/Server/WebServer+Tokens.swift | 83 ++++++++++ .../MistDemoKit/Server/WebServer.swift | 2 + .../Server/MockBackend+Calls.swift | 24 +++ .../MistDemoTests/Server/MockBackend.swift | 59 +++++++ .../Server/WebServerTests+Subscriptions.swift | 156 ++++++++++++++++++ .../Server/WebServerTests+Tokens.swift | 111 +++++++++++++ ...udKitResponseProcessor+Subscriptions.swift | 77 +++++++++ .../CloudKitResponseProcessor+Tokens.swift | 62 +++++++ .../CloudKitService+ModifySubscriptions.swift | 109 ++++++++++++ ...oudKitService+SubscriptionOperations.swift | 105 ++++++++++++ .../CloudKitService+TokenOperations.swift | 118 +++++++++++++ Sources/MistKit/Models/ConversionError.swift | 12 ++ .../Subscriptions/SubscriptionFireEvent.swift | 67 ++++++++ .../Subscriptions/SubscriptionInfo.swift | 142 ++++++++++++++++ .../Subscriptions/SubscriptionOperation.swift | 60 +++++++ .../Subscriptions/SubscriptionQuery.swift | 80 +++++++++ .../Subscriptions/SubscriptionType.swift | 59 +++++++ .../Models/Tokens/APNsEnvironment.swift | 55 ++++++ .../Models/Tokens/APNsTokenResult.swift | 65 ++++++++ Sources/MistKit/Models/Zones/ZoneID.swift | 2 +- .../MistKit/OpenAPI/OperationInputPath.swift | 10 ++ .../Operations.createToken.Output.swift | 30 ++-- .../Operations.listSubscriptions.Output.swift | 42 +++++ ...perations.lookupSubscriptions.Output.swift | 42 +++++ ...perations.modifySubscriptions.Output.swift | 42 +++++ .../Operations.registerToken.Output.swift | 42 +++++ Sources/MistKitOpenAPI/Types.swift | 72 ++++---- ...itServiceTests.Subscriptions+Helpers.swift | 73 ++++++++ ...viceTests.Subscriptions+SuccessCases.swift | 124 ++++++++++++++ .../CloudKitServiceTests.Subscriptions.swift | 24 +-- ...dKitServiceTests.Tokens+SuccessCases.swift | 79 +++++++++ .../Tokens/CloudKitServiceTests.Tokens.swift | 59 +++++++ .../SubscriptionConversionTests.swift | 154 +++++++++++++++++ .../Models/Tokens/APNsTokenResultTests.swift | 92 +++++++++++ openapi.yaml | 32 ++-- 56 files changed, 3179 insertions(+), 194 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Errors/SubscriptionCommandError.swift rename Examples/MistDemo/Sources/MistDemoKit/{Integration/Phases/ListSubscriptionsPhase.swift => Errors/TokenCommandError.swift} (61%) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SubscriptionRoundtripPhase.swift rename Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/{CreateTokenPhase.swift => TokenRoundtripPhase.swift} (64%) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Subscriptions.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Subscriptions.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Subscriptions.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift create mode 100644 Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Subscriptions.swift create mode 100644 Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Tokens.swift create mode 100644 Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift create mode 100644 Sources/MistKit/CloudKitService/CloudKitService+SubscriptionOperations.swift create mode 100644 Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift create mode 100644 Sources/MistKit/Models/Subscriptions/SubscriptionFireEvent.swift create mode 100644 Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift create mode 100644 Sources/MistKit/Models/Subscriptions/SubscriptionOperation.swift create mode 100644 Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift create mode 100644 Sources/MistKit/Models/Subscriptions/SubscriptionType.swift create mode 100644 Sources/MistKit/Models/Tokens/APNsEnvironment.swift create mode 100644 Sources/MistKit/Models/Tokens/APNsTokenResult.swift rename Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupSubscriptionPhase.swift => Sources/MistKit/OpenAPI/Operations/Operations.createToken.Output.swift (60%) create mode 100644 Sources/MistKit/OpenAPI/Operations/Operations.listSubscriptions.Output.swift create mode 100644 Sources/MistKit/OpenAPI/Operations/Operations.lookupSubscriptions.Output.swift create mode 100644 Sources/MistKit/OpenAPI/Operations/Operations.modifySubscriptions.Output.swift create mode 100644 Sources/MistKit/OpenAPI/Operations/Operations.registerToken.Output.swift create mode 100644 Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift create mode 100644 Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift rename Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RegisterTokenPhase.swift => Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions.swift (62%) create mode 100644 Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+SuccessCases.swift create mode 100644 Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens.swift create mode 100644 Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift create mode 100644 Tests/MistKitTests/Models/Tokens/APNsTokenResultTests.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift index 8cb0b636..1a024850 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift @@ -28,31 +28,31 @@ // internal import Foundation +internal import MistKit -/// Stub command for `tokens/create`. Creates an APNs token CloudKit can use -/// to deliver push notifications to a registered subscription. MistKit -/// Swift wrapper tracked in #52. -public struct CreateTokenCommand: MistDemoCommand { +/// 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 (pending #52)" + 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-token [--apns-environment ] + mistdemo create-token [--apns-environment ] OPTIONS: - --apns-token APNs device token (hex string) - --apns-environment APNs environment (development, production) + --apns-environment APNs environment, default development + --database Database to target --output-format Output format (json, table, csv, yaml) - STATUS: - Not yet implemented — pending MistKit support, tracked in #52. + EXAMPLES: + mistdemo create-token --apns-environment development """ private let config: CreateTokenConfig @@ -64,6 +64,22 @@ public struct CreateTokenCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { - PendingStub.printPending(endpoint: "tokens/create", trackingIssue: 52) + let environment = try resolveEnvironment() + let service = try MistKitClientFactory.create(for: config.base) + let result = try await service.createAPNsToken( + environment: environment, + 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 } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ListSubscriptionsCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ListSubscriptionsCommand.swift index e7a48f74..68360116 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ListSubscriptionsCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ListSubscriptionsCommand.swift @@ -28,17 +28,17 @@ // internal import Foundation +internal import MistKit -/// Stub command for `subscriptions/list`. Lists every CloudKit subscription -/// registered against the selected database. MistKit Swift wrapper tracked -/// in #49. -public struct ListSubscriptionsCommand: MistDemoCommand { +/// 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 (pending #49)" + public static let abstract = "List CloudKit subscriptions" /// The command help text. public static let helpText = """ LIST-SUBSCRIPTIONS - List CloudKit subscriptions @@ -50,8 +50,8 @@ public struct ListSubscriptionsCommand: MistDemoCommand { --database Database to target (private, shared, public) --output-format Output format (json, table, csv, yaml) - STATUS: - Not yet implemented — pending MistKit support, tracked in #49. + EXAMPLES: + mistdemo list-subscriptions --database private """ private let config: ListSubscriptionsConfig @@ -63,6 +63,8 @@ public struct ListSubscriptionsCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { - PendingStub.printPending(endpoint: "subscriptions/list", trackingIssue: 49) + 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/LookupSubscriptionCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupSubscriptionCommand.swift index a78bf14b..31d7cf00 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupSubscriptionCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupSubscriptionCommand.swift @@ -28,16 +28,17 @@ // internal import Foundation +internal import MistKit -/// Stub command for `subscriptions/lookup`. Looks up one or more -/// CloudKit subscriptions by ID. MistKit Swift wrapper tracked in #50. -public struct LookupSubscriptionCommand: MistDemoCommand { +/// 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 (pending #50)" + 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 @@ -50,8 +51,8 @@ public struct LookupSubscriptionCommand: MistDemoCommand { --database Database to target --output-format Output format (json, table, csv, yaml) - STATUS: - Not yet implemented — pending MistKit support, tracked in #50. + EXAMPLES: + mistdemo lookup-subscription --subscription-ids sub-1,sub-2 --database private """ private let config: LookupSubscriptionConfig @@ -63,6 +64,14 @@ public struct LookupSubscriptionCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { - PendingStub.printPending(endpoint: "subscriptions/lookup", trackingIssue: 50) + 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/ModifySubscriptionsCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift new file mode 100644 index 00000000..895250aa --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift @@ -0,0 +1,104 @@ +// +// 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 + } + + /// 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": + guard let recordType = config.recordType, !recordType.isEmpty else { + throw SubscriptionCommandError.missingRecordType + } + let firesOn = config.firesOn.compactMap(SubscriptionFireEvent.init(rawValue:)) + let created = try await service.createSubscription( + .query( + subscriptionID: subscriptionID, + recordType: recordType, + firesOn: firesOn + ), + database: config.base.database + ) + try await outputResult(created, format: config.output) + + case "delete": + try await service.deleteSubscription(id: subscriptionID, database: config.base.database) + print("✅ Deleted subscription '\(subscriptionID)'.") + + default: + throw SubscriptionCommandError.invalidOperation(config.operation) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift index ed11648e..ddb4d0ff 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift @@ -28,32 +28,31 @@ // internal import Foundation +internal import MistKit -/// Stub command for `tokens/register`. Wires an APNs token into a CloudKit -/// subscription so push notifications get delivered. MistKit Swift wrapper -/// tracked in #53. +/// Command for `tokens/register`. Registers a device's APNs token so CloudKit +/// delivers subscription-triggered pushes to it. The token itself is captured +/// from a real iOS/macOS device; `/tokens/register` takes only that token. 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 an APNs token with a subscription (pending #53)" + public static let abstract = "Register a device APNs token with CloudKit" /// The command help text. public static let helpText = """ - REGISTER-TOKEN - Register an APNs token with a CloudKit subscription + REGISTER-TOKEN - Register a device APNs token with CloudKit USAGE: - mistdemo register-token --apns-token --subscription-id + mistdemo register-token --apns-token OPTIONS: - --apns-token APNs device token (hex string) - --subscription-id CloudKit subscription ID + --apns-token APNs device token (hex string) from a device --database Database to target - --output-format Output format (json, table, csv, yaml) - STATUS: - Not yet implemented — pending MistKit support, tracked in #53. + EXAMPLES: + mistdemo register-token --apns-token 0a1b2c3d... --database private """ private let config: RegisterTokenConfig @@ -65,6 +64,11 @@ public struct RegisterTokenCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { - PendingStub.printPending(endpoint: "tokens/register", trackingIssue: 53) + guard let apnsToken = config.apnsToken, !apnsToken.isEmpty else { + throw TokenCommandError.missingAPNsToken + } + let service = try MistKitClientFactory.create(for: config.base) + try await service.registerAPNsToken(apnsToken, database: config.base.database) + print("✅ Registered APNs token with CloudKit.") } } 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/RegisterTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift index 0a2d15e7..92e30af0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift @@ -39,10 +39,8 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { /// The base MistDemo configuration. public let base: MistDemoConfig - /// APNs device token (hex string). + /// APNs device token (hex string) captured from a real device. public let apnsToken: String? - /// CloudKit subscription ID to wire the token into. - public let subscriptionID: String? /// The output format. public let output: OutputFormat @@ -50,12 +48,10 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { public init( base: MistDemoConfig, apnsToken: String? = nil, - subscriptionID: String? = nil, output: OutputFormat = .json ) { self.base = base self.apnsToken = apnsToken - self.subscriptionID = subscriptionID self.output = output } @@ -84,7 +80,6 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { self.init( base: baseConfig, apnsToken: configuration.string(forKey: "apns-token"), - subscriptionID: configuration.string(forKey: "subscription-id"), output: output ) } 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/Integration/Phases/ListSubscriptionsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/TokenCommandError.swift similarity index 61% rename from Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListSubscriptionsPhase.swift rename to Examples/MistDemo/Sources/MistDemoKit/Errors/TokenCommandError.swift index 1d77f267..81e220eb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListSubscriptionsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/TokenCommandError.swift @@ -1,5 +1,5 @@ // -// ListSubscriptionsPhase.swift +// TokenCommandError.swift // MistDemo // // Created by Leo Dion. @@ -27,22 +27,22 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import Foundation +public import Foundation -/// Stub phase for `subscriptions/list`. Not wired into the public/private -/// pipelines yet; `#49` flips this into a real run when the MistKit Swift -/// wrapper lands. -internal struct ListSubscriptionsPhase: IntegrationPhase { - internal typealias Input = NoState - internal typealias Output = NoState +/// 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 - internal static let title = "List subscriptions (pending #49)" - internal static let emoji = "🔔" - internal static let apiName = "listSubscriptions" - - internal func run(input: NoState, context: PhaseContext) async throws -> NoState { - print("\n\(Self.emoji) \(Self.title)") - PendingStub.printPending(endpoint: "subscriptions/list", trackingIssue: 49) - return NoState() + /// 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/Integration/Phases/SubscriptionRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SubscriptionRoundtripPhase.swift new file mode 100644 index 00000000..86825f6b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SubscriptionRoundtripPhase.swift @@ -0,0 +1,110 @@ +// +// 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)") + + let subscriptionID = "mistkit-itest-\(UUID().uuidString.lowercased())" + + let created = try await context.service.createSubscription( + .query( + subscriptionID: subscriptionID, + recordType: IntegrationTestData.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() + } + + /// 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/CreateTokenPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift similarity index 64% rename from Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateTokenPhase.swift rename to Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift index 99de8863..79eae45b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateTokenPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift @@ -1,5 +1,5 @@ // -// CreateTokenPhase.swift +// TokenRoundtripPhase.swift // MistDemo // // Created by Leo Dion. @@ -28,21 +28,33 @@ // internal import Foundation +internal import MistKit -/// Stub phase for `tokens/create`. Not wired into the public/private -/// pipelines yet; `#52` flips this into a real run when the MistKit Swift -/// wrapper lands. -internal struct CreateTokenPhase: IntegrationPhase { +/// 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 token (pending #52)" + internal static let title = "Create and register an APNs token" internal static let emoji = "🎟️" - internal static let apiName = "createToken" + internal static let apiName = "createToken+registerToken" internal func run(input: NoState, context: PhaseContext) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - PendingStub.printPending(endpoint: "tokens/create", trackingIssue: 52) + + let token = try await context.service.createAPNsToken( + environment: .development, + database: context.database + ) + if context.verbose { + print(" ✅ Created APNs token (\(token.apnsToken.prefix(8))…)") + } + + try await context.service.registerAPNsToken(token.apnsToken, database: context.database) + print("✅ Created and registered an APNs token") + return NoState() } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 2e1790dc..44cddb23 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -54,6 +54,8 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { ModifyRecordsPhase(), IncrementalSyncPhase(), FinalVerificationPhase(), + SubscriptionRoundtripPhase(), + TokenRoundtripPhase(), CleanupPhase(), ] } diff --git a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift index e38b85e1..7d6828c2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift @@ -71,6 +71,7 @@ public enum MistDemoRunner { await registry.register(RereferenceAssetCommand.self) await registry.register(ListSubscriptionsCommand.self) await registry.register(LookupSubscriptionCommand.self) + await registry.register(ModifySubscriptionsCommand.self) await registry.register(CreateTokenCommand.self) await registry.register(RegisterTokenCommand.self) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/subscriptions.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/subscriptions.js index dcc4c40d..1f704db3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/subscriptions.js +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/subscriptions.js @@ -1,6 +1,7 @@ -// subscriptions/list · subscriptions/lookup panel handlers. MistKit -// side returns 501 (pending #49 / #50); CloudKit JS side hits the real -// browser SDK primitives. +// 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'); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js index e8d68b0f..4afcdb0e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js @@ -1,8 +1,9 @@ // 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 is pending -// #52 (create) and #53 (register) — the 501 stubs render below. +// 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'); @@ -17,8 +18,13 @@ document.getElementById('tokens-register-btn').addEventListener('click', async ( 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; try { - result.register = await postJSON('/api/tokens/register', {}); + result.register = await postJSON('/api/tokens/register', + createdToken ? { apnsToken: createdToken } : {}); } catch (error) { result.register = error.payload || { message: error.message }; } renderRaw(tokensRaw, result); if (isPendingPayload(result.create) || isPendingPayload(result.register)) { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index 1731c941..965ab7b4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -72,6 +72,30 @@ internal protocol WebBackend: Sendable { delete: [String], database: MistKit.Database ) async throws -> [ZoneInfo] + + func webListSubscriptions( + database: MistKit.Database + ) async throws -> [SubscriptionInfo] + + func webLookupSubscriptions( + ids: [String], + database: MistKit.Database + ) async throws -> [SubscriptionInfo] + + func webModifySubscriptions( + operations: [SubscriptionOperation], + database: MistKit.Database + ) async throws -> [SubscriptionInfo] + + func webCreateToken( + environment: APNsEnvironment, + database: MistKit.Database + ) async throws -> APNsTokenResult + + func webRegisterToken( + apnsToken: String, + database: MistKit.Database + ) async throws } extension CloudKitService: WebBackend { @@ -148,4 +172,38 @@ extension CloudKitService: WebBackend { + 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] { + try await modifySubscriptions(operations, database: database) + } + + internal func webCreateToken( + environment: APNsEnvironment, + database: MistKit.Database + ) async throws -> APNsTokenResult { + try await createAPNsToken(environment: environment, database: database) + } + + internal func webRegisterToken( + apnsToken: String, + database: MistKit.Database + ) async throws { + try await registerAPNsToken(apnsToken, database: database) + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Subscriptions.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Subscriptions.swift new file mode 100644 index 00000000..50214cdb --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Subscriptions.swift @@ -0,0 +1,122 @@ +// +// WebRequests+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. +// + +internal import Foundation +internal import MistKit + +extension WebRequests { + /// `POST /api/subscriptions/modify` + /// + /// The browser sends one of two shapes, both handled here: + /// - a bare CloudKit-JS subscription object (create), e.g. + /// `{ "subscriptionType": "query", "subscriptionID": "x", + /// "firesOn": ["create"], "query": { "recordType": "Note" } }` + /// - a batch `{ "create": [...], "delete": [{ "subscriptionID": "x" }] }`. + /// + /// Both collapse into a single `[SubscriptionOperation]` via ``operations()``. + internal struct ModifySubscriptions: Decodable { + /// A subscription as posted by the browser (CloudKit JS shape). + internal struct SubscriptionInput: Decodable, Sendable { + internal struct QueryInput: Decodable, Sendable { + internal let recordType: String? + } + internal struct ZoneInput: Decodable, Sendable { + internal let zoneName: String? + } + + internal let subscriptionID: String? + internal let subscriptionType: String? + internal let firesOn: [String]? + internal let query: QueryInput? + internal let zoneID: ZoneInput? + } + + internal struct DeleteRef: Decodable, Sendable { + internal let subscriptionID: String + } + + private enum CodingKeys: String, CodingKey { + case create + case delete + case database + } + + internal let create: [SubscriptionInput] + internal let delete: [DeleteRef] + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.delete = + try container.decodeIfPresent([DeleteRef].self, forKey: .delete) ?? [] + + if let explicit = try container.decodeIfPresent( + [SubscriptionInput].self, forKey: .create + ) { + self.create = explicit + } else if let single = try? SubscriptionInput(from: decoder), + single.subscriptionType != nil + { + // Top-level bare subscription object (the create-panel shape). + self.create = [single] + } else { + self.create = [] + } + + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + + /// Collapse the decoded create/delete inputs into MistKit operations. + internal func operations() -> [SubscriptionOperation] { + var operations: [SubscriptionOperation] = create.map { input in + let firesOn = (input.firesOn ?? []).compactMap(SubscriptionFireEvent.init(rawValue:)) + if input.subscriptionType == "zone" { + return .create( + .zone( + subscriptionID: input.subscriptionID ?? "", + zoneID: ZoneID(zoneName: input.zoneID?.zoneName ?? ""), + firesOn: firesOn + ) + ) + } + return .create( + .query( + subscriptionID: input.subscriptionID ?? "", + recordType: input.query?.recordType ?? "", + firesOn: firesOn + ) + ) + } + operations += delete.map { .delete(subscriptionID: $0.subscriptionID) } + return operations + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift new file mode 100644 index 00000000..198cbceb --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift @@ -0,0 +1,78 @@ +// +// WebRequests+Tokens.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 WebRequests { + /// `POST /api/tokens` — mint a CloudKit-managed APNs token. + /// + /// `apnsEnvironment` defaults to `development`; an unrecognized value falls + /// back to `development` rather than failing the demo request. + internal struct CreateToken: Decodable { + private enum CodingKeys: String, CodingKey { + case apnsEnvironment + case database + } + + internal let environment: APNsEnvironment + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let raw = + try container.decodeIfPresent(String.self, forKey: .apnsEnvironment) + ?? "development" + self.environment = APNsEnvironment(rawValue: raw) ?? .development + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } + + /// `POST /api/tokens/register` — register a device APNs token. + internal struct RegisterToken: Decodable { + private enum CodingKeys: String, CodingKey { + case apnsToken + case database + } + + internal let apnsToken: String + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.apnsToken = + try container.decodeIfPresent(String.self, forKey: .apnsToken) ?? "" + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift index cdaf5265..0ee37b39 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift @@ -50,6 +50,31 @@ internal enum WebResponse { internal let zones: [ZoneInfo] } + /// Body returned by subscription routes (`list` / `lookup` / `modify`). + /// `SubscriptionInfo` encodes to `{ subscriptionID, subscriptionType, query, + /// zoneID, firesOn }`, matching the browser's subscription table and the + /// CloudKit JS `{ subscriptions: [...] }` shape. + internal struct Subscriptions: Encodable { + internal let subscriptions: [SubscriptionInfo] + } + + /// Body returned by `tokens/create`. Uses CloudKit's `webcAuthToken` wire + /// name so the panel shows the canonical field. + internal struct Token: Encodable { + internal let apnsToken: String + internal let webcAuthToken: String + + internal init(from result: APNsTokenResult) { + self.apnsToken = result.apnsToken + self.webcAuthToken = result.webAuthToken + } + } + + /// Body returned by `tokens/register` (no payload from CloudKit). + internal struct TokenRegistration: Encodable { + internal let registered: Bool + } + /// Body returned for any handled CloudKit/MistKit error so the UI can /// surface the message without parsing transport-level failures. internal struct Error: Encodable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Pending.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Pending.swift index 24b17e95..ad19d9e5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Pending.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Pending.swift @@ -94,41 +94,8 @@ endpoint: "assets/rereference", trackingIssue: 31 ) - Self.registerPending( - api: api, - verb: .get, - path: "subscriptions", - endpoint: "subscriptions/list", - trackingIssue: 49 - ) - Self.registerPending( - api: api, - verb: .get, - path: "subscriptions/:id", - endpoint: "subscriptions/lookup", - trackingIssue: 50 - ) - Self.registerPending( - api: api, - verb: .post, - path: "subscriptions/modify", - endpoint: "subscriptions/modify", - trackingIssue: 51 - ) - Self.registerPending( - api: api, - verb: .post, - path: "tokens", - endpoint: "tokens/create", - trackingIssue: 52 - ) - Self.registerPending( - api: api, - verb: .post, - path: "tokens/register", - endpoint: "tokens/register", - trackingIssue: 53 - ) + // subscriptions/* (#49/#50/#51) and tokens/* (#52/#53) are now wired to + // real MistKit handlers — see `WebServer+Subscriptions` / `WebServer+Tokens`. addUnwiredLandedEndpoints(api: api) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Subscriptions.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Subscriptions.swift new file mode 100644 index 00000000..87701888 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Subscriptions.swift @@ -0,0 +1,114 @@ +// +// WebServer+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(Hummingbird) + internal import Foundation + internal import Hummingbird + internal import MistKit + + extension WebServer { + /// `GET /api/subscriptions`, `GET /api/subscriptions/:id`, and + /// `POST /api/subscriptions/modify` — the MistKit-mode counterparts to the + /// CloudKit JS subscription primitives. These run against `.private` (the + /// browser panel doesn't carry a database picker for subscriptions). + internal func addSubscriptionEndpoints( + api: RouterGroup + ) { + addSubscriptionReadEndpoints(api: api) + addSubscriptionModifyEndpoint(api: api) + } + + private func addSubscriptionReadEndpoints( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + + api.get("subscriptions") { _, _ -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let subscriptions = try await backend.webListSubscriptions( + database: WebRequests.defaultDatabase + ) + return try WebJSON.encoder().encode( + WebResponse.Subscriptions(subscriptions: subscriptions) + ) + } + } + + api.get("subscriptions/:id") { _, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + guard let id = context.parameters.get("id") else { + return Response(status: .badRequest) + } + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let subscriptions = try await backend.webLookupSubscriptions( + ids: [id], + database: WebRequests.defaultDatabase + ) + return try WebJSON.encoder().encode( + WebResponse.Subscriptions(subscriptions: subscriptions) + ) + } + } + } + + private func addSubscriptionModifyEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + + api.post("subscriptions/modify") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.ModifySubscriptions.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let subscriptions = try await backend.webModifySubscriptions( + operations: body.operations(), + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.Subscriptions(subscriptions: subscriptions) + ) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift new file mode 100644 index 00000000..45aed739 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift @@ -0,0 +1,83 @@ +// +// WebServer+Tokens.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(Hummingbird) + internal import Foundation + internal import Hummingbird + internal import MistKit + + extension WebServer { + /// `POST /api/tokens` (create) and `POST /api/tokens/register` — the + /// MistKit-mode counterparts to CloudKit JS's + /// `container.registerForNotifications()`. Server-side callers mint a + /// CloudKit-managed token, then register it as the push destination. + internal func addTokenEndpoints( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + + api.post("tokens") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.CreateToken.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let result = try await backend.webCreateToken( + environment: body.environment, + database: body.database + ) + return try WebJSON.encoder().encode(WebResponse.Token(from: result)) + } + } + + api.post("tokens/register") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.RegisterToken.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + try await backend.webRegisterToken( + apnsToken: body.apnsToken, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.TokenRegistration(registered: true) + ) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift index ab0c1f6c..9fe87ae9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift @@ -128,6 +128,8 @@ addUpdateEndpoint(api: api) addDeleteEndpoint(api: api) addZonesModifyEndpoint(api: api) + addSubscriptionEndpoints(api: api) + addTokenEndpoints(api: api) addPendingEndpoints(api: api) return router diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index 5a054b17..9e213aed 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -71,5 +71,29 @@ internal let delete: [String] internal let database: MistKit.Database } + + /// Captured arguments from the most recent `webLookupSubscriptions` call. + internal struct LookupSubscriptionsCall: Sendable { + internal let ids: [String] + internal let database: MistKit.Database + } + + /// Captured arguments from the most recent `webModifySubscriptions` call. + internal struct ModifySubscriptionsCall: Sendable { + internal let operations: [SubscriptionOperation] + internal let database: MistKit.Database + } + + /// Captured arguments from the most recent `webCreateToken` call. + internal struct CreateTokenCall: Sendable { + internal let environment: APNsEnvironment + internal let database: MistKit.Database + } + + /// Captured arguments from the most recent `webRegisterToken` call. + internal struct RegisterTokenCall: Sendable { + internal let apnsToken: String + internal let database: MistKit.Database + } } #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index 0caee5f3..17fd28b3 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -41,8 +41,19 @@ internal private(set) var lastUpdate: UpdateCall? internal private(set) var lastDelete: DeleteCall? internal private(set) var lastModifyZones: ModifyZonesCall? + internal private(set) var didListSubscriptions = false + internal private(set) var lastLookupSubscriptions: LookupSubscriptionsCall? + internal private(set) var lastModifySubscriptions: ModifySubscriptionsCall? + internal private(set) var lastCreateToken: CreateTokenCall? + internal private(set) var lastRegisterToken: RegisterTokenCall? private var pendingError: String? + /// Subscriptions returned by the list/lookup/modify stubs. Tests can seed + /// this; defaults to one query subscription. + private var stubSubscriptions: [SubscriptionInfo] = [ + .query(subscriptionID: "stub-sub", recordType: "Note", firesOn: [.create]) + ] + private static func stubRecord( recordType: String, recordName: String ) -> RecordInfo { @@ -185,6 +196,54 @@ } } + internal func webListSubscriptions( + database: MistKit.Database + ) async throws -> [SubscriptionInfo] { + didListSubscriptions = true + try consumePendingError() + return stubSubscriptions + } + + internal func webLookupSubscriptions( + ids: [String], + database: MistKit.Database + ) async throws -> [SubscriptionInfo] { + lastLookupSubscriptions = LookupSubscriptionsCall(ids: ids, database: database) + try consumePendingError() + return stubSubscriptions.filter { ids.contains($0.subscriptionID) } + } + + internal func webModifySubscriptions( + operations: [SubscriptionOperation], + database: MistKit.Database + ) async throws -> [SubscriptionInfo] { + lastModifySubscriptions = ModifySubscriptionsCall( + operations: operations, database: database + ) + try consumePendingError() + return operations.compactMap { operation in + if case .create(let info) = operation { return info } + return nil + } + } + + internal func webCreateToken( + environment: APNsEnvironment, + database: MistKit.Database + ) async throws -> APNsTokenResult { + lastCreateToken = CreateTokenCall(environment: environment, database: database) + try consumePendingError() + return APNsTokenResult(apnsToken: "stub-apns", webAuthToken: "stub-webauth") + } + + internal func webRegisterToken( + apnsToken: String, + database: MistKit.Database + ) async throws { + lastRegisterToken = RegisterTokenCall(apnsToken: apnsToken, database: database) + try consumePendingError() + } + private func consumePendingError() throws { if let message = pendingError { pendingError = nil diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Subscriptions.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Subscriptions.swift new file mode 100644 index 00000000..df953b80 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Subscriptions.swift @@ -0,0 +1,156 @@ +// +// WebServerTests+Subscriptions.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 { + private struct SubscriptionsPayload: Decodable { + struct Sub: Decodable { + let subscriptionID: String + let subscriptionType: String + } + let subscriptions: [Sub] + } + + @Test("GET /api/subscriptions lists subscriptions via the backend") + internal func listSubscriptions() 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/subscriptions", method: .get) { + response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + SubscriptionsPayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.subscriptions.first?.subscriptionID == "stub-sub") + } + } + + let listed = await fixture.backend.didListSubscriptions + #expect(listed) + } + + @Test("GET /api/subscriptions/:id forwards the id to lookup") + internal func lookupSubscription() 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/subscriptions/stub-sub", method: .get) { + response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastLookupSubscriptions + #expect(captured?.ids == ["stub-sub"]) + } + + @Test("POST /api/subscriptions/modify forwards a bare create subscription") + internal func modifyCreateForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = + #"{"subscriptionType":"query","subscriptionID":"arts","firesOn":["# + + #""create","update"],"query":{"recordType":"Article"}}"# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/subscriptions/modify", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastModifySubscriptions + #expect(captured?.operations.count == 1) + if case .create(let info) = captured?.operations.first { + #expect(info.subscriptionID == "arts") + #expect(info.subscriptionType == .query) + #expect(info.query?.recordType == "Article") + #expect(info.firesOn == [.create, .update]) + } else { + Issue.record("Expected a create operation") + } + } + + @Test("POST /api/subscriptions/modify forwards a delete batch") + internal func modifyDeleteForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #"{"delete":[{"subscriptionID":"arts"}]}"# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/subscriptions/modify", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastModifySubscriptions + #expect(captured?.operations.count == 1) + if case .delete(let id) = captured?.operations.first { + #expect(id == "arts") + } else { + Issue.record("Expected a delete operation") + } + } + + @Test("subscription routes return 401 without a captured auth token") + internal func subscriptionsRequireAuth() async throws { + let fixture = Self.makeFixture(authenticated: false) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/api/subscriptions", method: .get) { + response in + #expect(response.status == .unauthorized) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift new file mode 100644 index 00000000..78964f69 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift @@ -0,0 +1,111 @@ +// +// WebServerTests+Tokens.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 { + private struct TokenPayload: Decodable { + let apnsToken: String + let webcAuthToken: String + } + + @Test("POST /api/tokens mints a token via the backend") + internal func createToken() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #"{"apnsEnvironment":"production"}"# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/tokens", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + TokenPayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.apnsToken == "stub-apns") + #expect(payload.webcAuthToken == "stub-webauth") + } + } + + let captured = await fixture.backend.lastCreateToken + #expect(captured?.environment == .production) + } + + @Test("POST /api/tokens/register forwards the token to the backend") + internal func registerToken() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #"{"apnsToken":"0a1b2c3d"}"# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/tokens/register", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastRegisterToken + #expect(captured?.apnsToken == "0a1b2c3d") + } + + @Test("token routes return 401 without a captured auth token") + internal func tokensRequireAuth() async throws { + let fixture = Self.makeFixture(authenticated: false) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/tokens", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: "{}") + ) { response in + #expect(response.status == .unauthorized) + } + } + } + } +#endif diff --git a/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Subscriptions.swift b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Subscriptions.swift new file mode 100644 index 00000000..06234310 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Subscriptions.swift @@ -0,0 +1,77 @@ +// +// CloudKitResponseProcessor+Subscriptions.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 MistKitOpenAPI + +extension CloudKitResponseProcessor { + /// Process listSubscriptions response. + internal func processListSubscriptionsResponse( + _ response: Operations.listSubscriptions.Output + ) async throws(CloudKitError) -> Components.Schemas.SubscriptionsListResponse { + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let subscriptionsData): + return subscriptionsData + } + case .badRequest, .unauthorized, .undocumented: + throw CloudKitError(response) ?? .invalidResponse + } + } + + /// Process lookupSubscriptions response. + internal func processLookupSubscriptionsResponse( + _ response: Operations.lookupSubscriptions.Output + ) async throws(CloudKitError) -> Components.Schemas.SubscriptionsLookupResponse { + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let subscriptionsData): + return subscriptionsData + } + case .badRequest, .unauthorized, .undocumented: + throw CloudKitError(response) ?? .invalidResponse + } + } + + /// Process modifySubscriptions response. + internal func processModifySubscriptionsResponse( + _ response: Operations.modifySubscriptions.Output + ) async throws(CloudKitError) -> Components.Schemas.SubscriptionsModifyResponse { + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let subscriptionsData): + return subscriptionsData + } + case .badRequest, .unauthorized, .undocumented: + throw CloudKitError(response) ?? .invalidResponse + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Tokens.swift b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Tokens.swift new file mode 100644 index 00000000..31481d40 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Tokens.swift @@ -0,0 +1,62 @@ +// +// CloudKitResponseProcessor+Tokens.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 MistKitOpenAPI + +extension CloudKitResponseProcessor { + /// Process createToken response. + internal func processCreateTokenResponse( + _ response: Operations.createToken.Output + ) async throws(CloudKitError) -> Components.Schemas.TokenResponse { + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let tokenData): + return tokenData + } + case .badRequest, .unauthorized, .undocumented: + throw CloudKitError(response) ?? .invalidResponse + } + } + + /// Process registerToken response. + /// + /// A successful registration returns an empty `200`; there is no body to + /// extract, so this validates the response and returns `Void`. + internal func processRegisterTokenResponse( + _ response: Operations.registerToken.Output + ) async throws(CloudKitError) { + switch response { + case .ok: + return + case .badRequest, .unauthorized, .undocumented: + throw CloudKitError(response) ?? .invalidResponse + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift new file mode 100644 index 00000000..19f71982 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift @@ -0,0 +1,109 @@ +// +// CloudKitService+ModifySubscriptions.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 MistKitOpenAPI + +extension CloudKitService { + /// Create, update, or delete subscriptions in a single request. + /// + /// - Parameters: + /// - operations: The subscription operations to apply. + /// - database: The CloudKit database scope to modify. + /// - Returns: The subscriptions returned by CloudKit (created/updated + /// subscriptions; deletions are typically omitted). + /// - Throws: ``CloudKitError`` if the request fails. + public func modifySubscriptions( + _ operations: [SubscriptionOperation], + database: Database + ) async throws(CloudKitError) -> [SubscriptionInfo] { + do { + let client = try self.client(for: database) + let response = try await client.modifySubscriptions( + .init( + path: Operations.modifySubscriptions.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init( + operations: operations.map { + Components.Schemas.SubscriptionOperation(from: $0) + } + ) + ) + ) + ) + + let subscriptionsData: Components.Schemas.SubscriptionsModifyResponse = + try await responseProcessor.processModifySubscriptionsResponse(response) + return try (subscriptionsData.subscriptions ?? []).map { + try SubscriptionInfo(from: $0) + } + } catch { + throw mapToCloudKitError(error, context: "modifySubscriptions") + } + } + + /// Create a single subscription. + /// + /// Convenience wrapper over ``modifySubscriptions(_:database:)``. Build the + /// `subscription` with ``SubscriptionInfo/query(subscriptionID:recordType:filters:sortBy:firesOn:)`` + /// or ``SubscriptionInfo/zone(subscriptionID:zoneID:firesOn:)``. + /// + /// - Parameters: + /// - subscription: The subscription to create. + /// - database: The CloudKit database scope to modify. + /// - Returns: The created subscription as returned by CloudKit. + /// - Throws: ``CloudKitError`` if the request fails or the response is empty. + @discardableResult + public func createSubscription( + _ subscription: SubscriptionInfo, + database: Database + ) async throws(CloudKitError) -> SubscriptionInfo { + let results = try await modifySubscriptions([.create(subscription)], database: database) + guard let created = results.first else { + throw CloudKitError.invalidResponse + } + return created + } + + /// Delete a single subscription by its identifier. + /// + /// - Parameters: + /// - id: The identifier of the subscription to delete. + /// - database: The CloudKit database scope to modify. + /// - Throws: ``CloudKitError`` if the request fails. + public func deleteSubscription( + id: String, + database: Database + ) async throws(CloudKitError) { + _ = try await modifySubscriptions([.delete(subscriptionID: id)], database: database) + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+SubscriptionOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+SubscriptionOperations.swift new file mode 100644 index 00000000..c32123f6 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+SubscriptionOperations.swift @@ -0,0 +1,105 @@ +// +// CloudKitService+SubscriptionOperations.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 MistKitOpenAPI + +extension CloudKitService { + /// List all subscriptions in the target database. + /// + /// Subscriptions are the change triggers that produce push notifications; + /// pair them with a registered APNs token + /// (``createAPNsToken(environment:database:)`` / + /// ``registerAPNsToken(_:database:)``) to receive pushes. + /// + /// - Parameter database: The CloudKit database scope to query. + /// - Returns: Every subscription registered in the database. + /// - Throws: ``CloudKitError`` if the request fails. + public func listSubscriptions( + database: Database + ) async throws(CloudKitError) -> [SubscriptionInfo] { + do { + let client = try self.client(for: database) + let response = try await client.listSubscriptions( + .init( + path: Operations.listSubscriptions.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ) + ) + ) + + let subscriptionsData: Components.Schemas.SubscriptionsListResponse = + try await responseProcessor.processListSubscriptionsResponse(response) + return try (subscriptionsData.subscriptions ?? []).map { + try SubscriptionInfo(from: $0) + } + } catch { + throw mapToCloudKitError(error, context: "listSubscriptions") + } + } + + /// Look up specific subscriptions by their identifiers. + /// + /// - Parameters: + /// - ids: The subscription identifiers to fetch. + /// - database: The CloudKit database scope to query. + /// - Returns: The matching subscriptions. + /// - Throws: ``CloudKitError`` if the request fails. + public func lookupSubscriptions( + ids: [String], + database: Database + ) async throws(CloudKitError) -> [SubscriptionInfo] { + do { + let client = try self.client(for: database) + let response = try await client.lookupSubscriptions( + .init( + path: Operations.lookupSubscriptions.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init( + subscriptions: ids.map { .init(subscriptionID: $0) } + ) + ) + ) + ) + + let subscriptionsData: Components.Schemas.SubscriptionsLookupResponse = + try await responseProcessor.processLookupSubscriptionsResponse(response) + return try (subscriptionsData.subscriptions ?? []).map { + try SubscriptionInfo(from: $0) + } + } catch { + throw mapToCloudKitError(error, context: "lookupSubscriptions") + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift new file mode 100644 index 00000000..5cca2148 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift @@ -0,0 +1,118 @@ +// +// CloudKitService+TokenOperations.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 MistKitOpenAPI + +extension CloudKitService { + /// Mint a CloudKit-managed APNs token for non-device callers. + /// + /// The native CloudKit framework doesn't need this — the OS binds the + /// signed-in iCloud account to APNs automatically. The REST surface has no + /// such binding, so a CloudKit JS browser client or a server process without a + /// device token mints one here to use as the destination for + /// subscription-triggered pushes. + /// + /// - Parameters: + /// - environment: The APNs environment the token targets. + /// - database: The CloudKit database scope. + /// - Returns: The minted ``APNsTokenResult`` (`apnsToken` + web-push auth + /// secret). + /// - Throws: ``CloudKitError`` if the request fails. + public func createAPNsToken( + environment: APNsEnvironment, + database: Database + ) async throws(CloudKitError) -> APNsTokenResult { + do { + let client = try self.client(for: database) + let response = try await client.createToken( + .init( + path: Operations.createToken.Input.Path( + containerIdentifier: containerIdentifier, + environment: self.environment, + database: database + ), + body: .json( + .init(apnsEnvironment: .init(from: environment)) + ) + ) + ) + + let tokenData: Components.Schemas.TokenResponse = + try await responseProcessor.processCreateTokenResponse(response) + return try APNsTokenResult(from: tokenData) + } catch { + throw mapToCloudKitError(error, context: "createAPNsToken") + } + } + + /// Register a device's APNs token so CloudKit pushes subscription-triggered + /// notifications to it. + /// + /// This is the device-side counterpart to + /// ``createAPNsToken(environment:database:)``: a real iOS/macOS device + /// registers with APNs the normal way, ships its hex token to your backend, + /// and the backend registers it here so CloudKit subscriptions in this + /// container deliver to that token. + /// + /// - Parameters: + /// - apnsToken: The device's APNs token, as a hex string. + /// - database: The CloudKit database scope. + /// - Throws: ``CloudKitError/badRequest(reason:)`` if `apnsToken` is empty, or + /// any error surfaced by the API. + public func registerAPNsToken( + _ apnsToken: String, + database: Database + ) async throws(CloudKitError) { + let trimmed = apnsToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw CloudKitError.badRequest(reason: "apnsToken must not be empty") + } + + do { + let client = try self.client(for: database) + let response = try await client.registerToken( + .init( + path: Operations.registerToken.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init(apnsToken: trimmed) + ) + ) + ) + + try await responseProcessor.processRegisterTokenResponse(response) + } catch { + throw mapToCloudKitError(error, context: "registerAPNsToken") + } + } +} diff --git a/Sources/MistKit/Models/ConversionError.swift b/Sources/MistKit/Models/ConversionError.swift index 3cab708f..b88284f8 100644 --- a/Sources/MistKit/Models/ConversionError.swift +++ b/Sources/MistKit/Models/ConversionError.swift @@ -64,6 +64,12 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { case zoneMissingName /// A user response was missing its `userRecordName`. case userMissingRecordName + /// A subscription response was missing its `subscriptionID`. + case subscriptionMissingID + /// A subscription response was missing its `subscriptionType`. + case subscriptionMissingType + /// A token response was missing a required field (`apnsToken`/`webcAuthToken`). + case tokenMissingField(fieldName: String) /// A human-readable description of what failed during conversion. public var errorDescription: String? { @@ -94,6 +100,12 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { return "Zone entry missing zoneName" case .userMissingRecordName: return "UserResponse missing userRecordName" + case .subscriptionMissingID: + return "Subscription entry missing subscriptionID" + case .subscriptionMissingType: + return "Subscription entry missing subscriptionType" + case .tokenMissingField(let fieldName): + return "TokenResponse missing required field '\(fieldName)'" } } } diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvent.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvent.swift new file mode 100644 index 00000000..710a558d --- /dev/null +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvent.swift @@ -0,0 +1,67 @@ +// +// SubscriptionFireEvent.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 MistKitOpenAPI + +/// A record-change event that causes a subscription to fire a push. +/// +/// A subscription's `firesOn` set selects which of these trigger a notification. +public enum SubscriptionFireEvent: String, Codable, Sendable, CaseIterable { + /// Fire when a matching record is created. + case create + /// Fire when a matching record is updated. + case update + /// Fire when a matching record is deleted. + case delete +} + +// MARK: - Internal Conversion +extension SubscriptionFireEvent { + internal var schemaValue: Components.Schemas.Subscription.firesOnPayloadPayload { + switch self { + case .create: + return .create + case .update: + return .update + case .delete: + return .delete + } + } + + internal init(from payload: Components.Schemas.Subscription.firesOnPayloadPayload) { + switch payload { + case .create: + self = .create + case .update: + self = .update + case .delete: + self = .delete + } + } +} diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift new file mode 100644 index 00000000..9b5cf829 --- /dev/null +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift @@ -0,0 +1,142 @@ +// +// SubscriptionInfo.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 MistKitOpenAPI + +/// A CloudKit subscription: the change trigger that produces push notifications. +/// +/// Returned by ``CloudKitService/listSubscriptions(database:)`` and +/// ``CloudKitService/lookupSubscriptions(ids:database:)``, and constructed to +/// create new subscriptions via +/// ``CloudKitService/modifySubscriptions(_:database:)``. +/// +/// Use ``query(subscriptionID:recordType:filters:sortBy:firesOn:)`` or +/// ``zone(subscriptionID:zoneID:firesOn:)`` to build one; CloudKit Web Services +/// requires the caller to supply the `subscriptionID`. +public struct SubscriptionInfo: Codable, Sendable { + /// The client-supplied unique identifier for the subscription. + public let subscriptionID: String + /// Whether this is a `query` or `zone` subscription. + public let subscriptionType: SubscriptionType + /// The watched query, for `query` subscriptions. + public let query: SubscriptionQuery? + /// The watched zone, for `zone` subscriptions. + public let zoneID: ZoneID? + /// The record-change events that trigger a push. + public let firesOn: [SubscriptionFireEvent] + + /// The OpenAPI schema representation used when sending this subscription in a + /// modify request. + internal var schema: Components.Schemas.Subscription { + Components.Schemas.Subscription( + subscriptionID: self.subscriptionID, + subscriptionType: self.subscriptionType.schemaValue, + query: self.query?.schema, + zoneID: self.zoneID.map { Components.Schemas.ZoneID(from: $0) }, + firesOn: self.firesOn.isEmpty ? nil : self.firesOn.map(\.schemaValue) + ) + } + + /// Initialize a subscription. + public init( + subscriptionID: String, + subscriptionType: SubscriptionType, + query: SubscriptionQuery? = nil, + zoneID: ZoneID? = nil, + firesOn: [SubscriptionFireEvent] = [] + ) { + self.subscriptionID = subscriptionID + self.subscriptionType = subscriptionType + self.query = query + self.zoneID = zoneID + self.firesOn = firesOn + } + + /// Convert a decoded `Subscription` payload into a `SubscriptionInfo`. + /// + /// A missing `subscriptionID`/`subscriptionType`, or a `zoneID` without a + /// `zoneName`, is a conversion failure (logged, asserted in DEBUG, and thrown) + /// rather than a silently-dropped subscription. + internal init(from subscription: Components.Schemas.Subscription) throws(ConversionError) { + guard let subscriptionID = subscription.subscriptionID else { + try ConversionError.subscriptionMissingID.reportAndThrow() + } + guard let subscriptionType = subscription.subscriptionType else { + try ConversionError.subscriptionMissingType.reportAndThrow() + } + + let zoneID: ZoneID? + if let payload = subscription.zoneID { + guard let zoneName = payload.zoneName else { + try ConversionError.zoneMissingName.reportAndThrow() + } + zoneID = ZoneID(zoneName: zoneName, ownerName: payload.ownerName) + } else { + zoneID = nil + } + + self.init( + subscriptionID: subscriptionID, + subscriptionType: SubscriptionType(from: subscriptionType), + query: subscription.query.map(SubscriptionQuery.init), + zoneID: zoneID, + firesOn: subscription.firesOn?.map(SubscriptionFireEvent.init(from:)) ?? [] + ) + } + + /// Build a `query` subscription that fires when matching records change. + public static func query( + subscriptionID: String, + recordType: String, + filters: [QueryFilter] = [], + sortBy: [QuerySort] = [], + firesOn: [SubscriptionFireEvent] + ) -> SubscriptionInfo { + SubscriptionInfo( + subscriptionID: subscriptionID, + subscriptionType: .query, + query: SubscriptionQuery(recordType: recordType, filters: filters, sortBy: sortBy), + firesOn: firesOn + ) + } + + /// Build a `zone` subscription that fires when any record in a zone changes. + public static func zone( + subscriptionID: String, + zoneID: ZoneID, + firesOn: [SubscriptionFireEvent] = [] + ) -> SubscriptionInfo { + SubscriptionInfo( + subscriptionID: subscriptionID, + subscriptionType: .zone, + zoneID: zoneID, + firesOn: firesOn + ) + } +} diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionOperation.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionOperation.swift new file mode 100644 index 00000000..b45b02e8 --- /dev/null +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionOperation.swift @@ -0,0 +1,60 @@ +// +// SubscriptionOperation.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 MistKitOpenAPI + +/// A create / update / delete operation against a CloudKit subscription, used by +/// ``CloudKitService/modifySubscriptions(_:database:)``. +public enum SubscriptionOperation: Sendable { + /// Create the given subscription. + case create(SubscriptionInfo) + + /// Update the given subscription. + case update(SubscriptionInfo) + + /// Delete the subscription with the given identifier. + case delete(subscriptionID: String) +} + +// MARK: - Internal Conversion +extension Components.Schemas.SubscriptionOperation { + internal init(from operation: SubscriptionOperation) { + switch operation { + case .create(let info): + self.init(operationType: .create, subscription: info.schema) + case .update(let info): + self.init(operationType: .update, subscription: info.schema) + case .delete(let subscriptionID): + self.init( + operationType: .delete, + subscription: Components.Schemas.Subscription(subscriptionID: subscriptionID) + ) + } + } +} diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift new file mode 100644 index 00000000..342372ed --- /dev/null +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift @@ -0,0 +1,80 @@ +// +// SubscriptionQuery.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 MistKitOpenAPI + +/// The query a `query`-type subscription watches. +/// +/// This reuses the exact same query model as a record query — a `recordType` +/// plus the same ``QueryFilter`` / ``QuerySort`` building blocks — so a +/// subscription's predicate is expressed identically to a one-off query passed +/// to `CloudKitService.queryRecords`. +public struct SubscriptionQuery: Codable, Sendable { + // MARK: - Internal + + internal let schema: Components.Schemas.Query + + // MARK: - Public + + /// The record type this query watches, as returned by CloudKit. + public var recordType: String? { + self.schema.recordType + } + + // MARK: - Lifecycle + + internal init(_ schema: Components.Schemas.Query) { + self.schema = schema + } + + /// Build a subscription query. + /// - Parameters: + /// - recordType: The record type the subscription watches. + /// - filters: Optional predicate filters (reusing ``QueryFilter``). + /// - sortBy: Optional sort descriptors (reusing ``QuerySort``). + public init( + recordType: String, + filters: [QueryFilter] = [], + sortBy: [QuerySort] = [] + ) { + self.schema = Components.Schemas.Query( + recordType: recordType, + filterBy: filters.isEmpty ? nil : filters.map(\.filter), + sortBy: sortBy.isEmpty ? nil : sortBy.map(\.sort) + ) + } + + public init(from decoder: any Decoder) throws { + self.schema = try Components.Schemas.Query(from: decoder) + } + + public func encode(to encoder: any Encoder) throws { + try self.schema.encode(to: encoder) + } +} diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionType.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionType.swift new file mode 100644 index 00000000..55fe0d63 --- /dev/null +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionType.swift @@ -0,0 +1,59 @@ +// +// SubscriptionType.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 MistKitOpenAPI + +/// The kind of change a CloudKit subscription watches for. +public enum SubscriptionType: String, Codable, Sendable, CaseIterable { + /// Fires when records matching a query change (see ``SubscriptionQuery``). + case query + /// Fires when any record in a record zone changes (see `zoneID`). + case zone +} + +// MARK: - Internal Conversion +extension SubscriptionType { + internal var schemaValue: Components.Schemas.Subscription.subscriptionTypePayload { + switch self { + case .query: + return .query + case .zone: + return .zone + } + } + + internal init(from payload: Components.Schemas.Subscription.subscriptionTypePayload) { + switch payload { + case .query: + self = .query + case .zone: + self = .zone + } + } +} diff --git a/Sources/MistKit/Models/Tokens/APNsEnvironment.swift b/Sources/MistKit/Models/Tokens/APNsEnvironment.swift new file mode 100644 index 00000000..fc48aa66 --- /dev/null +++ b/Sources/MistKit/Models/Tokens/APNsEnvironment.swift @@ -0,0 +1,55 @@ +// +// APNsEnvironment.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 MistKitOpenAPI + +/// The APNs environment a CloudKit-minted token targets. +/// +/// Passed to ``CloudKitService/createAPNsToken(environment:database:)`` to mint a +/// token for either the sandbox or production Apple Push Notification service. +public enum APNsEnvironment: String, Codable, Sendable, CaseIterable { + /// The APNs sandbox environment, paired with the CloudKit `development` + /// container environment. + case development + /// The APNs production environment, paired with the CloudKit `production` + /// container environment. + case production +} + +// MARK: - Internal Conversion +extension Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload { + internal init(from environment: APNsEnvironment) { + switch environment { + case .development: + self = .development + case .production: + self = .production + } + } +} diff --git a/Sources/MistKit/Models/Tokens/APNsTokenResult.swift b/Sources/MistKit/Models/Tokens/APNsTokenResult.swift new file mode 100644 index 00000000..fad35307 --- /dev/null +++ b/Sources/MistKit/Models/Tokens/APNsTokenResult.swift @@ -0,0 +1,65 @@ +// +// APNsTokenResult.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 MistKitOpenAPI + +/// A CloudKit-minted APNs token, returned by +/// ``CloudKitService/createAPNsToken(environment:database:)``. +/// +/// Used by non-device callers (CloudKit JS in a browser, server processes) that +/// have no device APNs token to register. The `apnsToken` becomes the push +/// destination for subscription-triggered notifications; the `webAuthToken` is +/// the web-push auth secret handed to a browser's web-push registration. +public struct APNsTokenResult: Codable, Sendable, Equatable { + /// The CloudKit-managed APNs token to use as a push destination. + public let apnsToken: String + /// The web-push auth secret (the API spells this `webcAuthToken`). + public let webAuthToken: String + + /// Initialize an APNs token result. + public init(apnsToken: String, webAuthToken: String) { + self.apnsToken = apnsToken + self.webAuthToken = webAuthToken + } + + /// Convert a decoded `TokenResponse` into an `APNsTokenResult`. + /// + /// Both fields are optional in the OpenAPI schema but required in a successful + /// response; a missing field is a conversion failure (logged, asserted in + /// DEBUG, and thrown) rather than a silently-empty token. + internal init(from response: Components.Schemas.TokenResponse) throws(ConversionError) { + guard let apnsToken = response.apnsToken else { + try ConversionError.tokenMissingField(fieldName: "apnsToken").reportAndThrow() + } + guard let webcAuthToken = response.webcAuthToken else { + try ConversionError.tokenMissingField(fieldName: "webcAuthToken").reportAndThrow() + } + self.init(apnsToken: apnsToken, webAuthToken: webcAuthToken) + } +} diff --git a/Sources/MistKit/Models/Zones/ZoneID.swift b/Sources/MistKit/Models/Zones/ZoneID.swift index 2e991c28..cd2b4b9e 100644 --- a/Sources/MistKit/Models/Zones/ZoneID.swift +++ b/Sources/MistKit/Models/Zones/ZoneID.swift @@ -34,7 +34,7 @@ internal import MistKitOpenAPI /// /// Zone IDs uniquely identify a record zone within a database. /// The _defaultZone is automatically available in all databases. -public struct ZoneID: Sendable, Equatable, Hashable { +public struct ZoneID: Codable, Sendable, Equatable, Hashable { /// The default zone present in all databases public static let defaultZone = ZoneID(zoneName: "_defaultZone", ownerName: nil) diff --git a/Sources/MistKit/OpenAPI/OperationInputPath.swift b/Sources/MistKit/OpenAPI/OperationInputPath.swift index b0a9b7f9..9ccdd562 100644 --- a/Sources/MistKit/OpenAPI/OperationInputPath.swift +++ b/Sources/MistKit/OpenAPI/OperationInputPath.swift @@ -86,3 +86,13 @@ extension Operations.modifyZones.Input.Path: OperationInputPath {} extension Operations.queryRecords.Input.Path: OperationInputPath {} extension Operations.uploadAssets.Input.Path: OperationInputPath {} + +extension Operations.listSubscriptions.Input.Path: OperationInputPath {} + +extension Operations.lookupSubscriptions.Input.Path: OperationInputPath {} + +extension Operations.modifySubscriptions.Input.Path: OperationInputPath {} + +extension Operations.createToken.Input.Path: OperationInputPath {} + +extension Operations.registerToken.Input.Path: OperationInputPath {} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupSubscriptionPhase.swift b/Sources/MistKit/OpenAPI/Operations/Operations.createToken.Output.swift similarity index 60% rename from Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupSubscriptionPhase.swift rename to Sources/MistKit/OpenAPI/Operations/Operations.createToken.Output.swift index f9cab48f..3d7f86c1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupSubscriptionPhase.swift +++ b/Sources/MistKit/OpenAPI/Operations/Operations.createToken.Output.swift @@ -1,6 +1,6 @@ // -// LookupSubscriptionPhase.swift -// MistDemo +// Operations.createToken.Output.swift +// MistKit // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -27,22 +27,16 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import Foundation +internal import MistKitOpenAPI -/// Stub phase for `subscriptions/lookup`. Not wired into the public/private -/// pipelines yet; `#50` flips this into a real run when the MistKit Swift -/// wrapper lands. -internal struct LookupSubscriptionPhase: IntegrationPhase { - internal typealias Input = NoState - internal typealias Output = NoState - - internal static let title = "Lookup subscription (pending #50)" - internal static let emoji = "🔍" - internal static let apiName = "lookupSubscription" - - internal func run(input: NoState, context: PhaseContext) async throws -> NoState { - print("\n\(Self.emoji) \(Self.title)") - PendingStub.printPending(endpoint: "subscriptions/lookup", trackingIssue: 50) - return NoState() +extension Operations.createToken.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } } } diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.listSubscriptions.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.listSubscriptions.Output.swift new file mode 100644 index 00000000..f8f9c724 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.listSubscriptions.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.listSubscriptions.Output.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 MistKitOpenAPI + +extension Operations.listSubscriptions.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.lookupSubscriptions.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.lookupSubscriptions.Output.swift new file mode 100644 index 00000000..dc9f2dd3 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.lookupSubscriptions.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.lookupSubscriptions.Output.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 MistKitOpenAPI + +extension Operations.lookupSubscriptions.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.modifySubscriptions.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.modifySubscriptions.Output.swift new file mode 100644 index 00000000..bd192089 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.modifySubscriptions.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.modifySubscriptions.Output.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 MistKitOpenAPI + +extension Operations.modifySubscriptions.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.registerToken.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.registerToken.Output.swift new file mode 100644 index 00000000..c60a082c --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.registerToken.Output.swift @@ -0,0 +1,42 @@ +// +// Operations.registerToken.Output.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 MistKitOpenAPI + +extension Operations.registerToken.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index f613e955..8ddf66da 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -650,6 +650,39 @@ public enum Components { case ascending } } + /// A record query, shared by records/query and query subscriptions + /// + /// - Remark: Generated from `#/components/schemas/Query`. + public struct Query: Codable, Hashable, Sendable { + /// The record type to query + /// + /// - Remark: Generated from `#/components/schemas/Query/recordType`. + public var recordType: Swift.String? + /// - Remark: Generated from `#/components/schemas/Query/filterBy`. + public var filterBy: [Components.Schemas.Filter]? + /// - Remark: Generated from `#/components/schemas/Query/sortBy`. + public var sortBy: [Components.Schemas.Sort]? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - recordType: The record type to query + /// - filterBy: + /// - sortBy: + public init( + recordType: Swift.String? = nil, + filterBy: [Components.Schemas.Filter]? = nil, + sortBy: [Components.Schemas.Sort]? = nil + ) { + self.recordType = recordType + self.filterBy = filterBy + self.sortBy = sortBy + } + public enum CodingKeys: String, CodingKey { + case recordType + case filterBy + case sortBy + } + } /// - Remark: Generated from `#/components/schemas/RecordOperation`. public struct RecordOperation: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RecordOperation/operationType`. @@ -1533,7 +1566,7 @@ public enum Components { /// - Remark: Generated from `#/components/schemas/Subscription/subscriptionType`. public var subscriptionType: Components.Schemas.Subscription.subscriptionTypePayload? /// - Remark: Generated from `#/components/schemas/Subscription/query`. - public var query: OpenAPIRuntime.OpenAPIObjectContainer? + public var query: Components.Schemas.Query? /// - Remark: Generated from `#/components/schemas/Subscription/zoneID`. public var zoneID: Components.Schemas.ZoneID? /// - Remark: Generated from `#/components/schemas/Subscription/firesOnPayload`. @@ -1557,7 +1590,7 @@ public enum Components { public init( subscriptionID: Swift.String? = nil, subscriptionType: Components.Schemas.Subscription.subscriptionTypePayload? = nil, - query: OpenAPIRuntime.OpenAPIObjectContainer? = nil, + query: Components.Schemas.Query? = nil, zoneID: Components.Schemas.ZoneID? = nil, firesOn: Components.Schemas.Subscription.firesOnPayload? = nil ) { @@ -2524,38 +2557,7 @@ public enum Operations { /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/resultsLimit`. public var resultsLimit: Swift.Int? /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query`. - public struct queryPayload: Codable, Hashable, Sendable { - /// The record type to query - /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query/recordType`. - public var recordType: Swift.String? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query/filterBy`. - public var filterBy: [Components.Schemas.Filter]? - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query/sortBy`. - public var sortBy: [Components.Schemas.Sort]? - /// Creates a new `queryPayload`. - /// - /// - Parameters: - /// - recordType: The record type to query - /// - filterBy: - /// - sortBy: - public init( - recordType: Swift.String? = nil, - filterBy: [Components.Schemas.Filter]? = nil, - sortBy: [Components.Schemas.Sort]? = nil - ) { - self.recordType = recordType - self.filterBy = filterBy - self.sortBy = sortBy - } - public enum CodingKeys: String, CodingKey { - case recordType - case filterBy - case sortBy - } - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/query`. - public var query: Operations.queryRecords.Input.Body.jsonPayload.queryPayload? + public var query: Components.Schemas.Query? /// List of field names to return /// /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/desiredKeys`. @@ -2575,7 +2577,7 @@ public enum Operations { public init( zoneID: Components.Schemas.ZoneID? = nil, resultsLimit: Swift.Int? = nil, - query: Operations.queryRecords.Input.Body.jsonPayload.queryPayload? = nil, + query: Components.Schemas.Query? = nil, desiredKeys: [Swift.String]? = nil, continuationMarker: Swift.String? = nil ) { diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift new file mode 100644 index 00000000..316e9e0b --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift @@ -0,0 +1,73 @@ +// +// CloudKitServiceTests.Subscriptions+Helpers.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 HTTPTypes +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Subscriptions { + /// A two-subscription payload: one query subscription, one zone subscription. + internal static let sampleListJSON = """ + { + "subscriptions": [ + { + "subscriptionID": "query-sub", + "subscriptionType": "query", + "query": { "recordType": "Article" }, + "firesOn": ["create", "update"] + }, + { + "subscriptionID": "zone-sub", + "subscriptionType": "zone", + "zoneID": { "zoneName": "Photos", "ownerName": "_defaultOwner" }, + "firesOn": ["create", "update", "delete"] + } + ] + } + """ + + internal static func makeService(returningJSON json: String) throws -> CloudKitService { + var headers = HTTPFields() + headers[.contentType] = "application/json" + let response = ResponseConfig( + statusCode: 200, + headers: headers, + body: Data(json.utf8), + error: nil + ) + let transport = MockTransport(responseProvider: ResponseProvider(defaultResponse: response)) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: transport + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift new file mode 100644 index 00000000..a9b003af --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift @@ -0,0 +1,124 @@ +// +// CloudKitServiceTests.Subscriptions+SuccessCases.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.Subscriptions { + @Suite("Success Cases") + internal struct SuccessCases { + private static let database: Database = .public(.prefers(.serverToServer)) + + @Test("listSubscriptions() decodes query and zone subscriptions") + internal func listDecodesBoth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Subscriptions.makeService( + returningJSON: CloudKitServiceTests.Subscriptions.sampleListJSON + ) + + let subscriptions = try await service.listSubscriptions(database: Self.database) + + #expect(subscriptions.count == 2) + let query = try #require(subscriptions.first { $0.subscriptionType == .query }) + #expect(query.subscriptionID == "query-sub") + #expect(query.query?.recordType == "Article") + #expect(query.firesOn == [.create, .update]) + + let zone = try #require(subscriptions.first { $0.subscriptionType == .zone }) + #expect(zone.zoneID == ZoneID(zoneName: "Photos", ownerName: "_defaultOwner")) + #expect(zone.firesOn == [.create, .update, .delete]) + } + + @Test("lookupSubscriptions() returns the matching subscriptions") + internal func lookupReturnsMatches() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Subscriptions.makeService( + returningJSON: CloudKitServiceTests.Subscriptions.sampleListJSON + ) + + let subscriptions = try await service.lookupSubscriptions( + ids: ["query-sub", "zone-sub"], + database: Self.database + ) + #expect(subscriptions.count == 2) + } + + @Test("modifySubscriptions() returns the created subscription") + internal func modifyReturnsCreated() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let json = """ + { + "subscriptions": [ + { + "subscriptionID": "new-sub", + "subscriptionType": "query", + "query": { "recordType": "Article" }, + "firesOn": ["create"] + } + ] + } + """ + let service = try CloudKitServiceTests.Subscriptions.makeService(returningJSON: json) + + let created = try await service.createSubscription( + .query( + subscriptionID: "new-sub", + recordType: "Article", + firesOn: [.create] + ), + database: Self.database + ) + #expect(created.subscriptionID == "new-sub") + #expect(created.query?.recordType == "Article") + } + + @Test("deleteSubscription() completes against an empty subscriptions response") + internal func deleteCompletes() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Subscriptions.makeService( + returningJSON: #"{ "subscriptions": [] }"# + ) + try await service.deleteSubscription(id: "query-sub", database: Self.database) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RegisterTokenPhase.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions.swift similarity index 62% rename from Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RegisterTokenPhase.swift rename to Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions.swift index d7535da9..12f80509 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RegisterTokenPhase.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions.swift @@ -1,6 +1,6 @@ // -// RegisterTokenPhase.swift -// MistDemo +// CloudKitServiceTests.Subscriptions.swift +// MistKit // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -28,21 +28,11 @@ // internal import Foundation +internal import Testing -/// Stub phase for `tokens/register`. Not wired into the public/private -/// pipelines yet; `#53` flips this into a real run when the MistKit Swift -/// wrapper lands. -internal struct RegisterTokenPhase: IntegrationPhase { - internal typealias Input = NoState - internal typealias Output = NoState +@testable import MistKit - internal static let title = "Register token (pending #53)" - internal static let emoji = "📨" - internal static let apiName = "registerToken" - - internal func run(input: NoState, context: PhaseContext) async throws -> NoState { - print("\n\(Self.emoji) \(Self.title)") - PendingStub.printPending(endpoint: "tokens/register", trackingIssue: 53) - return NoState() - } +extension CloudKitServiceTests { + @Suite("CloudKitService Subscription Operations", .enabled(if: Platform.isCryptoAvailable)) + internal enum Subscriptions {} } diff --git a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+SuccessCases.swift new file mode 100644 index 00000000..bd0d39c2 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+SuccessCases.swift @@ -0,0 +1,79 @@ +// +// CloudKitServiceTests.Tokens+SuccessCases.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.Tokens { + @Suite("Success Cases") + internal struct SuccessCases { + private static let database: Database = .public(.prefers(.serverToServer)) + + @Test("createAPNsToken() decodes apnsToken and renamed webcAuthToken") + internal func createDecodesTokens() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let json = #"{ "apnsToken": "apns-123", "webcAuthToken": "web-456" }"# + let service = try CloudKitServiceTests.Tokens.makeService(json: json) + + let result = try await service.createAPNsToken( + environment: .development, + database: Self.database + ) + #expect(result.apnsToken == "apns-123") + #expect(result.webAuthToken == "web-456") + } + + @Test("registerAPNsToken() completes against an empty 200") + internal func registerCompletes() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Tokens.makeService() + try await service.registerAPNsToken("abcdef0123456789", database: Self.database) + } + + @Test("registerAPNsToken() rejects an empty token before dispatching") + internal func registerRejectsEmpty() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Tokens.makeService() + await #expect(throws: CloudKitError.self) { + try await service.registerAPNsToken(" ", database: Self.database) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens.swift b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens.swift new file mode 100644 index 00000000..3555e945 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens.swift @@ -0,0 +1,59 @@ +// +// CloudKitServiceTests.Tokens.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 HTTPTypes +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite("CloudKitService Token Operations", .enabled(if: Platform.isCryptoAvailable)) + internal enum Tokens { + internal static func makeService( + statusCode: Int = 200, + json: String = "" + ) throws -> CloudKitService { + var headers = HTTPFields() + headers[.contentType] = "application/json" + let response = ResponseConfig( + statusCode: statusCode, + headers: headers, + body: Data(json.utf8), + error: nil + ) + let transport = MockTransport(responseProvider: ResponseProvider(defaultResponse: response)) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: transport + ) + } + } +} diff --git a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift new file mode 100644 index 00000000..d71a22a1 --- /dev/null +++ b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift @@ -0,0 +1,154 @@ +// +// SubscriptionConversionTests.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 MistKitOpenAPI +internal import Testing + +@testable import MistKit + +@Suite("Subscription ⇄ OpenAPI Conversion") +internal struct SubscriptionConversionTests { + /// Runs `body`, expecting it to throw `error`, with the DEBUG assertion + /// handler suppressed so the throw is observed rather than trapped. + private func expectThrow( + _ error: E, + _ body: () throws -> Void + ) { + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect(throws: error) { + try body() + } + } + ) + } + + @Test("query SubscriptionInfo round-trips through the OpenAPI schema") + internal func queryRoundTrip() throws { + let info = SubscriptionInfo.query( + subscriptionID: "sub-1", + recordType: "Article", + filters: [.equals("published", .string("true"))], + sortBy: [.ascending("title")], + firesOn: [.create, .update] + ) + + let schema = info.schema + #expect(schema.subscriptionID == "sub-1") + #expect(schema.subscriptionType == .query) + #expect(schema.query?.recordType == "Article") + #expect(schema.query?.filterBy?.count == 1) + #expect(schema.query?.sortBy?.count == 1) + #expect(schema.firesOn == [.create, .update]) + #expect(schema.zoneID == nil) + + let recovered = try SubscriptionInfo(from: schema) + #expect(recovered.subscriptionID == "sub-1") + #expect(recovered.subscriptionType == .query) + #expect(recovered.query?.recordType == "Article") + #expect(recovered.zoneID == nil) + #expect(recovered.firesOn == [.create, .update]) + } + + @Test("zone SubscriptionInfo round-trips through the OpenAPI schema") + internal func zoneRoundTrip() throws { + let info = SubscriptionInfo.zone( + subscriptionID: "sub-zone", + zoneID: ZoneID(zoneName: "Photos", ownerName: "_owner"), + firesOn: [.delete] + ) + + let schema = info.schema + #expect(schema.subscriptionType == .zone) + #expect(schema.zoneID?.zoneName == "Photos") + #expect(schema.zoneID?.ownerName == "_owner") + #expect(schema.query == nil) + + let recovered = try SubscriptionInfo(from: schema) + #expect(recovered.subscriptionType == .zone) + #expect(recovered.zoneID == ZoneID(zoneName: "Photos", ownerName: "_owner")) + #expect(recovered.query == nil) + #expect(recovered.firesOn == [.delete]) + } + + @Test("missing subscriptionID is a conversion failure") + internal func missingIDThrows() throws { + expectThrow(ConversionError.subscriptionMissingID) { + _ = try SubscriptionInfo(from: Components.Schemas.Subscription(subscriptionType: .query)) + } + } + + @Test("missing subscriptionType is a conversion failure") + internal func missingTypeThrows() throws { + expectThrow(ConversionError.subscriptionMissingType) { + _ = try SubscriptionInfo(from: Components.Schemas.Subscription(subscriptionID: "x")) + } + } + + @Test("zone subscription without a zoneName is a conversion failure") + internal func zoneWithoutNameThrows() throws { + let payload = Components.Schemas.Subscription( + subscriptionID: "z", + subscriptionType: .zone, + zoneID: Components.Schemas.ZoneID(ownerName: "_owner") + ) + expectThrow(ConversionError.zoneMissingName) { + _ = try SubscriptionInfo(from: payload) + } + } + + @Test("SubscriptionOperation maps to the matching operationType") + internal func operationMapping() throws { + let info = SubscriptionInfo.query( + subscriptionID: "op", recordType: "T", firesOn: [.create] + ) + + let create = Components.Schemas.SubscriptionOperation(from: .create(info)) + #expect(create.operationType == .create) + #expect(create.subscription?.subscriptionID == "op") + + let update = Components.Schemas.SubscriptionOperation(from: .update(info)) + #expect(update.operationType == .update) + + let delete = Components.Schemas.SubscriptionOperation(from: .delete(subscriptionID: "gone")) + #expect(delete.operationType == .delete) + #expect(delete.subscription?.subscriptionID == "gone") + } + + @Test("firesOn defaults to empty when the payload omits it") + internal func firesOnDefaultsEmpty() throws { + let payload = Components.Schemas.Subscription( + subscriptionID: "n", subscriptionType: .query + ) + let recovered = try SubscriptionInfo(from: payload) + #expect(recovered.firesOn.isEmpty) + } +} diff --git a/Tests/MistKitTests/Models/Tokens/APNsTokenResultTests.swift b/Tests/MistKitTests/Models/Tokens/APNsTokenResultTests.swift new file mode 100644 index 00000000..22a97e55 --- /dev/null +++ b/Tests/MistKitTests/Models/Tokens/APNsTokenResultTests.swift @@ -0,0 +1,92 @@ +// +// APNsTokenResultTests.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 MistKitOpenAPI +internal import Testing + +@testable import MistKit + +@Suite("APNsTokenResult Conversion") +internal struct APNsTokenResultTests { + /// Runs `body`, expecting it to throw `error`, with the DEBUG assertion + /// handler suppressed so the throw is observed rather than trapped. + private func expectThrow( + _ error: E, + _ body: () throws -> Void + ) { + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect(throws: error) { + try body() + } + } + ) + } + + @Test("maps TokenResponse fields, renaming webcAuthToken") + internal func mapsFields() throws { + let response = Components.Schemas.TokenResponse( + apnsToken: "apns-abc", + webcAuthToken: "web-xyz" + ) + let result = try APNsTokenResult(from: response) + #expect(result.apnsToken == "apns-abc") + #expect(result.webAuthToken == "web-xyz") + } + + @Test("missing apnsToken is a conversion failure") + internal func missingAPNsTokenThrows() throws { + let response = Components.Schemas.TokenResponse(webcAuthToken: "web-xyz") + expectThrow(ConversionError.tokenMissingField(fieldName: "apnsToken")) { + _ = try APNsTokenResult(from: response) + } + } + + @Test("missing webcAuthToken is a conversion failure") + internal func missingWebAuthTokenThrows() throws { + let response = Components.Schemas.TokenResponse(apnsToken: "apns-abc") + expectThrow(ConversionError.tokenMissingField(fieldName: "webcAuthToken")) { + _ = try APNsTokenResult(from: response) + } + } + + @Test("APNsEnvironment maps to the createToken payload") + internal func environmentMapping() { + #expect( + Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload(from: .development) + == .development + ) + #expect( + Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload(from: .production) + == .production + ) + } +} diff --git a/openapi.yaml b/openapi.yaml index 08516f64..fb7a60b8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -56,19 +56,7 @@ paths: type: integer description: Maximum number of records to return query: - type: object - properties: - recordType: - type: string - description: The record type to query - filterBy: - type: array - items: - $ref: '#/components/schemas/Filter' - sortBy: - type: array - items: - $ref: '#/components/schemas/Sort' + $ref: '#/components/schemas/Query' desiredKeys: type: array items: @@ -951,6 +939,22 @@ components: ascending: type: boolean + Query: + type: object + description: A record query, shared by records/query and query subscriptions + properties: + recordType: + type: string + description: The record type to query + filterBy: + type: array + items: + $ref: '#/components/schemas/Filter' + sortBy: + type: array + items: + $ref: '#/components/schemas/Sort' + RecordOperation: type: object properties: @@ -1205,7 +1209,7 @@ components: type: string enum: [query, zone] query: - type: object + $ref: '#/components/schemas/Query' zoneID: $ref: '#/components/schemas/ZoneID' firesOn: From ad3ea98a7a1b4531d9e8301155749fa22b0d940b Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sun, 24 May 2026 10:15:48 -0400 Subject: [PATCH 02/14] Fix subscription delete-ack crash and token endpoint path (#379) [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by running the live MistDemo integration suite against a real container. - modifySubscriptions: CloudKit echoes a deleted subscription as a bare { subscriptionID } with no subscriptionType. Filter those deletion acknowledgements instead of fatal-erroring in SubscriptionInfo(from:). - tokens/create + tokens/register: the endpoints are container-scoped (/database/{version}/{container}/{environment}/tokens/...) with no {database} segment. Corrected openapi.yaml + regenerated; added ContainerOperationInputPath for the database-less path init. (The live endpoints still return 405 server-side — tracked in #379.) Co-Authored-By: Claude Opus 4.7 --- .../CloudKitService+ModifySubscriptions.swift | 12 +- .../CloudKitService+TokenOperations.swift | 16 ++- .../OpenAPI/ContainerOperationInputPath.swift | 63 ++++++++++ .../MistKit/OpenAPI/OperationInputPath.swift | 4 - Sources/MistKitOpenAPI/Client.swift | 18 ++- Sources/MistKitOpenAPI/Types.swift | 110 +++++++----------- openapi.yaml | 6 +- 7 files changed, 135 insertions(+), 94 deletions(-) create mode 100644 Sources/MistKit/OpenAPI/ContainerOperationInputPath.swift diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift index 19f71982..73cce5d1 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift @@ -63,8 +63,16 @@ extension CloudKitService { let subscriptionsData: Components.Schemas.SubscriptionsModifyResponse = try await responseProcessor.processModifySubscriptionsResponse(response) - return try (subscriptionsData.subscriptions ?? []).map { - try SubscriptionInfo(from: $0) + return try (subscriptionsData.subscriptions ?? []).compactMap { + subscription -> SubscriptionInfo? in + // CloudKit echoes a deleted subscription as a bare `{ subscriptionID }` + // with no `subscriptionType`/`query`/`firesOn`. That's a deletion + // acknowledgement, not a subscription — skip it rather than treating + // the missing type as a conversion failure. + guard subscription.subscriptionType != nil else { + return nil + } + return try SubscriptionInfo(from: subscription) } } catch { throw mapToCloudKitError(error, context: "modifySubscriptions") diff --git a/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift index 5cca2148..384c5675 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift @@ -41,7 +41,10 @@ extension CloudKitService { /// /// - Parameters: /// - environment: The APNs environment the token targets. - /// - database: The CloudKit database scope. + /// - database: The CloudKit database whose credentials authenticate the + /// request. The `tokens/create` endpoint is container-scoped — the + /// database is not part of its path — but still needs credentials, so the + /// caller picks which database's auth to present. /// - Returns: The minted ``APNsTokenResult`` (`apnsToken` + web-push auth /// secret). /// - Throws: ``CloudKitError`` if the request fails. @@ -55,8 +58,7 @@ extension CloudKitService { .init( path: Operations.createToken.Input.Path( containerIdentifier: containerIdentifier, - environment: self.environment, - database: database + environment: self.environment ), body: .json( .init(apnsEnvironment: .init(from: environment)) @@ -83,7 +85,10 @@ extension CloudKitService { /// /// - Parameters: /// - apnsToken: The device's APNs token, as a hex string. - /// - database: The CloudKit database scope. + /// - database: The CloudKit database whose credentials authenticate the + /// request. The `tokens/register` endpoint is container-scoped — the + /// database is not part of its path — but still needs credentials, so the + /// caller picks which database's auth to present. /// - Throws: ``CloudKitError/badRequest(reason:)`` if `apnsToken` is empty, or /// any error surfaced by the API. public func registerAPNsToken( @@ -101,8 +106,7 @@ extension CloudKitService { .init( path: Operations.registerToken.Input.Path( containerIdentifier: containerIdentifier, - environment: environment, - database: database + environment: environment ), body: .json( .init(apnsToken: trimmed) diff --git a/Sources/MistKit/OpenAPI/ContainerOperationInputPath.swift b/Sources/MistKit/OpenAPI/ContainerOperationInputPath.swift new file mode 100644 index 00000000..3a5ac194 --- /dev/null +++ b/Sources/MistKit/OpenAPI/ContainerOperationInputPath.swift @@ -0,0 +1,63 @@ +// +// ContainerOperationInputPath.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 MistKitOpenAPI + +/// Shared shape of the container-scoped `Operations.*.Input.Path` types. +/// +/// The APNs token endpoints (`tokens/create`, `tokens/register`) live at +/// `/database/{version}/{container}/{environment}/...` — they are not +/// database-scoped, so their generated `Input.Path` omits the `database` +/// segment. This sibling of ``OperationInputPath`` unlocks the same +/// MistKit-flavored convenience init without that parameter. +internal protocol ContainerOperationInputPath { + init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment + ) +} + +extension ContainerOperationInputPath { + /// Initialize from MistKit configuration components. + internal init( + containerIdentifier: String, + environment: Environment + ) { + self.init( + version: "1", + container: containerIdentifier, + environment: .init(from: environment) + ) + } +} + +extension Operations.createToken.Input.Path: ContainerOperationInputPath {} + +extension Operations.registerToken.Input.Path: ContainerOperationInputPath {} diff --git a/Sources/MistKit/OpenAPI/OperationInputPath.swift b/Sources/MistKit/OpenAPI/OperationInputPath.swift index 9ccdd562..88a9fe3f 100644 --- a/Sources/MistKit/OpenAPI/OperationInputPath.swift +++ b/Sources/MistKit/OpenAPI/OperationInputPath.swift @@ -92,7 +92,3 @@ extension Operations.listSubscriptions.Input.Path: OperationInputPath {} extension Operations.lookupSubscriptions.Input.Path: OperationInputPath {} extension Operations.modifySubscriptions.Input.Path: OperationInputPath {} - -extension Operations.createToken.Input.Path: OperationInputPath {} - -extension Operations.registerToken.Input.Path: OperationInputPath {} diff --git a/Sources/MistKitOpenAPI/Client.swift b/Sources/MistKitOpenAPI/Client.swift index e66261a5..2dcedb95 100644 --- a/Sources/MistKitOpenAPI/Client.swift +++ b/Sources/MistKitOpenAPI/Client.swift @@ -3391,20 +3391,19 @@ public struct Client: APIProtocol { /// /// Create an Apple Push Notification service (APNs) token /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/create`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)`. public func createToken(_ input: Operations.createToken.Input) async throws -> Operations.createToken.Output { try await client.send( input: input, forOperation: Operations.createToken.id, serializer: { input in let path = try converter.renderedPath( - template: "/database/{}/{}/{}/{}/tokens/create", + template: "/database/{}/{}/{}/tokens/create", parameters: [ input.path.version, input.path.container, - input.path.environment, - input.path.database + input.path.environment ] ) var request: HTTPTypes.HTTPRequest = .init( @@ -3511,20 +3510,19 @@ public struct Client: APIProtocol { /// /// Register a token for push notifications /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/register`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)`. public func registerToken(_ input: Operations.registerToken.Input) async throws -> Operations.registerToken.Output { try await client.send( input: input, forOperation: Operations.registerToken.id, serializer: { input in let path = try converter.renderedPath( - template: "/database/{}/{}/{}/{}/tokens/register", + template: "/database/{}/{}/{}/tokens/register", parameters: [ input.path.version, input.path.container, - input.path.environment, - input.path.database + input.path.environment ] ) var request: HTTPTypes.HTTPRequest = .init( diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 8ddf66da..65c50b18 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -162,15 +162,15 @@ public protocol APIProtocol: Sendable { /// /// Create an Apple Push Notification service (APNs) token /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/create`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)`. func createToken(_ input: Operations.createToken.Input) async throws -> Operations.createToken.Output /// Register Token /// /// Register a token for push notifications /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/register`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)`. func registerToken(_ input: Operations.registerToken.Input) async throws -> Operations.registerToken.Output } @@ -497,8 +497,8 @@ extension APIProtocol { /// /// Create an Apple Push Notification service (APNs) token /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/create`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)`. public func createToken( path: Operations.createToken.Input.Path, headers: Operations.createToken.Input.Headers = .init(), @@ -514,8 +514,8 @@ extension APIProtocol { /// /// Register a token for push notifications /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/register`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)`. public func registerToken( path: Operations.registerToken.Input.Path, headers: Operations.registerToken.Input.Headers = .init(), @@ -9390,16 +9390,16 @@ public enum Operations { /// /// Create an Apple Push Notification service (APNs) token /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)`. + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/create`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)`. public enum createToken { public static let id: Swift.String = "createToken" public struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/path`. public struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/version`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/path/version`. public var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/container`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/path/container`. public var container: Components.Parameters.container /// Container environment /// @@ -9408,39 +9408,26 @@ public enum Operations { case development = "development" case production = "production" } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/environment`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/path/environment`. public var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/path/database`. - public var database: Components.Parameters.database /// Creates a new `Path`. /// /// - Parameters: /// - version: /// - container: /// - environment: - /// - database: public init( version: Components.Parameters.version, container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database + environment: Components.Parameters.environment ) { self.version = version self.container = container self.environment = environment - self.database = database } } public var path: Operations.createToken.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/header`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/header`. public struct Headers: Sendable, Hashable { public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. @@ -9452,16 +9439,16 @@ public enum Operations { } } public var headers: Operations.createToken.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/json`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody/json`. public struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/json/apnsEnvironment`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody/json/apnsEnvironment`. @frozen public enum apnsEnvironmentPayload: String, Codable, Hashable, Sendable, CaseIterable { case development = "development" case production = "production" } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/json/apnsEnvironment`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody/json/apnsEnvironment`. public var apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload? /// Creates a new `jsonPayload`. /// @@ -9474,7 +9461,7 @@ public enum Operations { case apnsEnvironment } } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/requestBody/content/application\/json`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody/content/application\/json`. case json(Operations.createToken.Input.Body.jsonPayload) } public var body: Operations.createToken.Input.Body @@ -9496,9 +9483,9 @@ public enum Operations { } @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/responses/200/content`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/responses/200/content`. @frozen public enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/create/POST/responses/200/content/application\/json`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/responses/200/content/application\/json`. case json(Components.Schemas.TokenResponse) /// The associated value of the enum case if `self` is `.json`. /// @@ -9525,7 +9512,7 @@ public enum Operations { } /// Token created successfully /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)/responses/200`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.createToken.Output.Ok) @@ -9563,7 +9550,7 @@ public enum Operations { /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) /// /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)/responses/400`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Components.Responses.Failure) @@ -9601,7 +9588,7 @@ public enum Operations { /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) /// /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/create/post(createToken)/responses/401`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)/responses/401`. /// /// HTTP response code: `401 unauthorized`. case unauthorized(Components.Responses.Failure) @@ -9657,16 +9644,16 @@ public enum Operations { /// /// Register a token for push notifications /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)`. + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/register`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)`. public enum registerToken { public static let id: Swift.String = "registerToken" public struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/path`. public struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/version`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/path/version`. public var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/container`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/path/container`. public var container: Components.Parameters.container /// Container environment /// @@ -9675,39 +9662,26 @@ public enum Operations { case development = "development" case production = "production" } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/environment`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/path/environment`. public var environment: Components.Parameters.environment - /// Database scope - /// - /// - Remark: Generated from `#/components/parameters/database`. - @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { - case _public = "public" - case _private = "private" - case shared = "shared" - } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/path/database`. - public var database: Components.Parameters.database /// Creates a new `Path`. /// /// - Parameters: /// - version: /// - container: /// - environment: - /// - database: public init( version: Components.Parameters.version, container: Components.Parameters.container, - environment: Components.Parameters.environment, - database: Components.Parameters.database + environment: Components.Parameters.environment ) { self.version = version self.container = container self.environment = environment - self.database = database } } public var path: Operations.registerToken.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/header`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/header`. public struct Headers: Sendable, Hashable { public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. @@ -9719,13 +9693,13 @@ public enum Operations { } } public var headers: Operations.registerToken.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody/json`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json`. public struct jsonPayload: Codable, Hashable, Sendable { /// The APNs token to register /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody/json/apnsToken`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsToken`. public var apnsToken: Swift.String? /// Creates a new `jsonPayload`. /// @@ -9738,7 +9712,7 @@ public enum Operations { case apnsToken } } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/tokens/register/POST/requestBody/content/application\/json`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/content/application\/json`. case json(Operations.registerToken.Input.Body.jsonPayload) } public var body: Operations.registerToken.Input.Body @@ -9765,13 +9739,13 @@ public enum Operations { } /// Token registered successfully /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/200`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.registerToken.Output.Ok) /// Token registered successfully /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/200`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/200`. /// /// HTTP response code: `200 ok`. public static var ok: Self { @@ -9811,7 +9785,7 @@ public enum Operations { /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) /// /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/400`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Components.Responses.Failure) @@ -9849,7 +9823,7 @@ public enum Operations { /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) /// /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/tokens/register/post(registerToken)/responses/401`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/401`. /// /// HTTP response code: `401 unauthorized`. case unauthorized(Components.Responses.Failure) diff --git a/openapi.yaml b/openapi.yaml index fb7a60b8..1154063f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -801,7 +801,7 @@ paths: '401': $ref: '#/components/responses/Failure' - /database/{version}/{container}/{environment}/{database}/tokens/create: + /database/{version}/{container}/{environment}/tokens/create: post: summary: Create APNs Token description: Create an Apple Push Notification service (APNs) token @@ -812,7 +812,6 @@ paths: - $ref: '#/components/parameters/version' - $ref: '#/components/parameters/container' - $ref: '#/components/parameters/environment' - - $ref: '#/components/parameters/database' requestBody: required: true content: @@ -835,7 +834,7 @@ paths: '401': $ref: '#/components/responses/Failure' - /database/{version}/{container}/{environment}/{database}/tokens/register: + /database/{version}/{container}/{environment}/tokens/register: post: summary: Register Token description: Register a token for push notifications @@ -846,7 +845,6 @@ paths: - $ref: '#/components/parameters/version' - $ref: '#/components/parameters/container' - $ref: '#/components/parameters/environment' - - $ref: '#/components/parameters/database' requestBody: required: true content: From 9ca433388f9f4a56d0b78c26305fc9c78c798250 Mon Sep 17 00:00:00 2001 From: leogdion Date: Sun, 24 May 2026 11:15:39 -0400 Subject: [PATCH 03/14] Fix APNs token spec to match Apple's archived REST docs (#379) [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checked against Apple's authoritative reference: - developer.apple.com/library/archive/.../CreateTokens.html - developer.apple.com/library/archive/.../RegisterTokens.html Two real spec bugs surfaced, independent of the ongoing 405 mystery: 1. tokens/register was missing apnsEnvironment in the request body. Apple lists both apnsEnvironment + apnsToken as Required. Updated openapi.yaml; registerAPNsToken now takes (token, environment, db). 2. TokenResponse shape was wrong. Apple returns { apnsEnvironment, apnsToken, webcourierURL } (long-poll URL for browser/SW callers). Ours had a speculative `webcAuthToken` and no webcourierURL. Reshaped TokenResponse + APNsTokenResult; webcourierURL is now a URL on the domain type, environment is echoed back. Regenerated MistKitOpenAPI; updated CLI (--apns-environment flag on register-token), MistDemo integration phase, web routes + JS panel, and tests. 498 MistKit + 941 MistDemo unit tests pass; lint clean. Live test-private still fails at Phase 16 (TokenRoundtripPhase) — createAPNsToken returns 405 before we ever reach the corrected registerAPNsToken. Skip CI: integration scope didn't change, and the live live-suite failure is by design pending the next diagnostic pass (see #379 comment). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/RegisterTokenCommand.swift | 28 ++++++-- .../Configuration/RegisterTokenConfig.swift | 7 ++ .../Phases/TokenRoundtripPhase.swift | 6 +- .../MistDemoKit/Resources/js/tokens.js | 6 +- .../MistDemoKit/Server/WebBackend.swift | 8 ++- .../Server/WebRequests+Tokens.swift | 9 +++ .../MistDemoKit/Server/WebResponse.swift | 11 +-- .../MistDemoKit/Server/WebServer+Tokens.swift | 1 + .../Server/MockBackend+Calls.swift | 1 + .../MistDemoTests/Server/MockBackend.swift | 13 +++- .../Server/WebServerTests+Tokens.swift | 11 +-- .../CloudKitService+TokenOperations.swift | 19 +++-- Sources/MistKit/Models/ConversionError.swift | 3 +- .../Models/Tokens/APNsEnvironment.swift | 22 ++++++ .../Models/Tokens/APNsTokenResult.swift | 39 ++++++---- Sources/MistKitOpenAPI/Types.swift | 71 +++++++++++++++---- ...dKitServiceTests.Tokens+SuccessCases.swift | 25 +++++-- .../Models/Tokens/APNsTokenResultTests.swift | 47 ++++++++---- openapi.yaml | 30 +++++++- 19 files changed, 286 insertions(+), 71 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift index ddb4d0ff..0d5831d8 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift @@ -31,8 +31,9 @@ internal import Foundation internal import MistKit /// Command for `tokens/register`. Registers a device's APNs token so CloudKit -/// delivers subscription-triggered pushes to it. The token itself is captured -/// from a real iOS/macOS device; `/tokens/register` takes only that token. +/// 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 @@ -45,14 +46,16 @@ public struct RegisterTokenCommand: MistDemoCommand { REGISTER-TOKEN - Register a device APNs token with CloudKit USAGE: - mistdemo register-token --apns-token + mistdemo register-token --apns-token [--apns-environment ] OPTIONS: --apns-token APNs device token (hex string) from a device + --apns-environment APNs environment, default development --database Database to target EXAMPLES: - mistdemo register-token --apns-token 0a1b2c3d... --database private + mistdemo register-token --apns-token 0a1b2c3d... \ + --apns-environment development --database private """ private let config: RegisterTokenConfig @@ -67,8 +70,23 @@ public struct RegisterTokenCommand: MistDemoCommand { 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, database: config.base.database) + try await service.registerAPNsToken( + apnsToken, + environment: environment, + 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 + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift index 92e30af0..100c2c87 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift @@ -41,6 +41,10 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { 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? /// The output format. public let output: OutputFormat @@ -48,10 +52,12 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { public init( base: MistDemoConfig, apnsToken: String? = nil, + apnsEnvironment: String? = nil, output: OutputFormat = .json ) { self.base = base self.apnsToken = apnsToken + self.apnsEnvironment = apnsEnvironment self.output = output } @@ -80,6 +86,7 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { self.init( base: baseConfig, apnsToken: configuration.string(forKey: "apns-token"), + apnsEnvironment: configuration.string(forKey: "apns-environment"), output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift index 79eae45b..9205943c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift @@ -52,7 +52,11 @@ internal struct TokenRoundtripPhase: IntegrationPhase { print(" ✅ Created APNs token (\(token.apnsToken.prefix(8))…)") } - try await context.service.registerAPNsToken(token.apnsToken, database: context.database) + try await context.service.registerAPNsToken( + token.apnsToken, + environment: token.environment, + database: context.database + ) print("✅ Created and registered an APNs token") return NoState() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js index 4afcdb0e..26723981 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js @@ -22,9 +22,13 @@ document.getElementById('tokens-register-btn').addEventListener('click', async ( // 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 } : {}); + createdToken + ? { apnsToken: createdToken, apnsEnvironment: createdEnvironment } + : {}); } catch (error) { result.register = error.payload || { message: error.message }; } renderRaw(tokensRaw, result); if (isPendingPayload(result.create) || isPendingPayload(result.register)) { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index 965ab7b4..722086cc 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -94,6 +94,7 @@ internal protocol WebBackend: Sendable { func webRegisterToken( apnsToken: String, + environment: APNsEnvironment, database: MistKit.Database ) async throws } @@ -202,8 +203,13 @@ extension CloudKitService: WebBackend { internal func webRegisterToken( apnsToken: String, + environment: APNsEnvironment, database: MistKit.Database ) async throws { - try await registerAPNsToken(apnsToken, database: database) + try await registerAPNsToken( + apnsToken, + environment: environment, + database: database + ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift index 198cbceb..0ea76243 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift @@ -57,19 +57,28 @@ extension WebRequests { } /// `POST /api/tokens/register` — register a device APNs token. + /// + /// `apnsEnvironment` defaults to `development`; an unrecognized value falls + /// back to `development` to keep the demo lenient. internal struct RegisterToken: Decodable { private enum CodingKeys: String, CodingKey { case apnsToken + case apnsEnvironment case database } internal let apnsToken: String + internal let environment: APNsEnvironment internal let database: MistKit.Database internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.apnsToken = try container.decodeIfPresent(String.self, forKey: .apnsToken) ?? "" + let raw = + try container.decodeIfPresent(String.self, forKey: .apnsEnvironment) + ?? "development" + self.environment = APNsEnvironment(rawValue: raw) ?? .development self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift index 0ee37b39..103bb91b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift @@ -58,15 +58,18 @@ internal enum WebResponse { internal let subscriptions: [SubscriptionInfo] } - /// Body returned by `tokens/create`. Uses CloudKit's `webcAuthToken` wire - /// name so the panel shows the canonical field. + /// Body returned by `tokens/create`. Echoes CloudKit's wire field names so + /// the demo panel shows the canonical shape per Apple's `CreateTokens.html` + /// REST reference: `apnsEnvironment`, `apnsToken`, `webcourierURL`. internal struct Token: Encodable { + internal let apnsEnvironment: APNsEnvironment internal let apnsToken: String - internal let webcAuthToken: String + internal let webcourierURL: URL internal init(from result: APNsTokenResult) { + self.apnsEnvironment = result.environment self.apnsToken = result.apnsToken - self.webcAuthToken = result.webAuthToken + self.webcourierURL = result.webcourierURL } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift index 45aed739..9cd47995 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift @@ -71,6 +71,7 @@ let backend = try backendFactory.make(token) try await backend.webRegisterToken( apnsToken: body.apnsToken, + environment: body.environment, database: body.database ) return try WebJSON.encoder().encode( diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index 9e213aed..8c92bdee 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -93,6 +93,7 @@ /// Captured arguments from the most recent `webRegisterToken` call. internal struct RegisterTokenCall: Sendable { internal let apnsToken: String + internal let environment: APNsEnvironment internal let database: MistKit.Database } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index 17fd28b3..96daecae 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -233,14 +233,23 @@ ) async throws -> APNsTokenResult { lastCreateToken = CreateTokenCall(environment: environment, database: database) try consumePendingError() - return APNsTokenResult(apnsToken: "stub-apns", webAuthToken: "stub-webauth") + return APNsTokenResult( + environment: environment, + apnsToken: "stub-apns", + webcourierURL: URL(string: "https://stub.example/webcourier")! + ) } internal func webRegisterToken( apnsToken: String, + environment: APNsEnvironment, database: MistKit.Database ) async throws { - lastRegisterToken = RegisterTokenCall(apnsToken: apnsToken, database: database) + lastRegisterToken = RegisterTokenCall( + apnsToken: apnsToken, + environment: environment, + database: database + ) try consumePendingError() } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift index 78964f69..6b2fb2fd 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift @@ -39,8 +39,9 @@ extension WebServerTests { private struct TokenPayload: Decodable { + let apnsEnvironment: String let apnsToken: String - let webcAuthToken: String + let webcourierURL: URL } @Test("POST /api/tokens mints a token via the backend") @@ -61,8 +62,9 @@ TokenPayload.self, from: Data(response.body.readableBytesView) ) + #expect(payload.apnsEnvironment == "production") #expect(payload.apnsToken == "stub-apns") - #expect(payload.webcAuthToken == "stub-webauth") + #expect(payload.webcourierURL == URL(string: "https://stub.example/webcourier")) } } @@ -70,11 +72,11 @@ #expect(captured?.environment == .production) } - @Test("POST /api/tokens/register forwards the token to the backend") + @Test("POST /api/tokens/register forwards the token + environment to the backend") internal func registerToken() async throws { let fixture = Self.makeFixture(authenticated: true) let app = Application(router: try fixture.server.makeRouter()) - let jsonBody = #"{"apnsToken":"0a1b2c3d"}"# + let jsonBody = #"{"apnsToken":"0a1b2c3d","apnsEnvironment":"production"}"# try await app.test(.router) { client in try await client.execute( @@ -89,6 +91,7 @@ let captured = await fixture.backend.lastRegisterToken #expect(captured?.apnsToken == "0a1b2c3d") + #expect(captured?.environment == .production) } @Test("token routes return 401 without a captured auth token") diff --git a/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift index 384c5675..e78bea6e 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift @@ -45,8 +45,9 @@ extension CloudKitService { /// request. The `tokens/create` endpoint is container-scoped — the /// database is not part of its path — but still needs credentials, so the /// caller picks which database's auth to present. - /// - Returns: The minted ``APNsTokenResult`` (`apnsToken` + web-push auth - /// secret). + /// - Returns: The minted ``APNsTokenResult`` (`apnsToken`, echoed + /// `environment`, and `webcourierURL` for browser/Service-Worker push + /// delivery). /// - Throws: ``CloudKitError`` if the request fails. public func createAPNsToken( environment: APNsEnvironment, @@ -83,8 +84,14 @@ extension CloudKitService { /// and the backend registers it here so CloudKit subscriptions in this /// container deliver to that token. /// + /// Per Apple's `RegisterTokens.html` REST reference, both `apnsEnvironment` + /// and `apnsToken` are required in the request body — the environment is not + /// inferred from the URL. + /// /// - Parameters: /// - apnsToken: The device's APNs token, as a hex string. + /// - environment: The APNs environment the token targets (must match the + /// environment under which the device registered with APNs). /// - database: The CloudKit database whose credentials authenticate the /// request. The `tokens/register` endpoint is container-scoped — the /// database is not part of its path — but still needs credentials, so the @@ -93,6 +100,7 @@ extension CloudKitService { /// any error surfaced by the API. public func registerAPNsToken( _ apnsToken: String, + environment: APNsEnvironment, database: Database ) async throws(CloudKitError) { let trimmed = apnsToken.trimmingCharacters(in: .whitespacesAndNewlines) @@ -106,10 +114,13 @@ extension CloudKitService { .init( path: Operations.registerToken.Input.Path( containerIdentifier: containerIdentifier, - environment: environment + environment: self.environment ), body: .json( - .init(apnsToken: trimmed) + .init( + apnsEnvironment: .init(from: environment), + apnsToken: trimmed + ) ) ) ) diff --git a/Sources/MistKit/Models/ConversionError.swift b/Sources/MistKit/Models/ConversionError.swift index b88284f8..25b6e6d2 100644 --- a/Sources/MistKit/Models/ConversionError.swift +++ b/Sources/MistKit/Models/ConversionError.swift @@ -68,7 +68,8 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { case subscriptionMissingID /// A subscription response was missing its `subscriptionType`. case subscriptionMissingType - /// A token response was missing a required field (`apnsToken`/`webcAuthToken`). + /// A token response was missing or malformed a required field + /// (`apnsEnvironment`/`apnsToken`/`webcourierURL`). case tokenMissingField(fieldName: String) /// A human-readable description of what failed during conversion. diff --git a/Sources/MistKit/Models/Tokens/APNsEnvironment.swift b/Sources/MistKit/Models/Tokens/APNsEnvironment.swift index fc48aa66..f082dec5 100644 --- a/Sources/MistKit/Models/Tokens/APNsEnvironment.swift +++ b/Sources/MistKit/Models/Tokens/APNsEnvironment.swift @@ -53,3 +53,25 @@ extension Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload { } } } + +extension Operations.registerToken.Input.Body.jsonPayload.apnsEnvironmentPayload { + internal init(from environment: APNsEnvironment) { + switch environment { + case .development: + self = .development + case .production: + self = .production + } + } +} + +extension APNsEnvironment { + internal init(from payload: Components.Schemas.TokenResponse.apnsEnvironmentPayload) { + switch payload { + case .development: + self = .development + case .production: + self = .production + } + } +} diff --git a/Sources/MistKit/Models/Tokens/APNsTokenResult.swift b/Sources/MistKit/Models/Tokens/APNsTokenResult.swift index fad35307..dae78d8f 100644 --- a/Sources/MistKit/Models/Tokens/APNsTokenResult.swift +++ b/Sources/MistKit/Models/Tokens/APNsTokenResult.swift @@ -27,6 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // +public import Foundation internal import MistKitOpenAPI /// A CloudKit-minted APNs token, returned by @@ -34,32 +35,40 @@ internal import MistKitOpenAPI /// /// Used by non-device callers (CloudKit JS in a browser, server processes) that /// have no device APNs token to register. The `apnsToken` becomes the push -/// destination for subscription-triggered notifications; the `webAuthToken` is -/// the web-push auth secret handed to a browser's web-push registration. +/// destination for subscription-triggered notifications. `webcourierURL` is the +/// long-poll endpoint browser / Service-Worker clients use to receive those +/// notifications; server callers receive pushes via APNs proper and can usually +/// ignore it. public struct APNsTokenResult: Codable, Sendable, Equatable { + /// The APNs environment the token targets (echoes the request). + public let environment: APNsEnvironment /// The CloudKit-managed APNs token to use as a push destination. public let apnsToken: String - /// The web-push auth secret (the API spells this `webcAuthToken`). - public let webAuthToken: String + /// Long-poll URL that browser / Service-Worker callers use to receive + /// CloudKit-triggered push notifications. + public let webcourierURL: URL /// Initialize an APNs token result. - public init(apnsToken: String, webAuthToken: String) { + public init(environment: APNsEnvironment, apnsToken: String, webcourierURL: URL) { + self.environment = environment self.apnsToken = apnsToken - self.webAuthToken = webAuthToken + self.webcourierURL = webcourierURL } /// Convert a decoded `TokenResponse` into an `APNsTokenResult`. /// - /// Both fields are optional in the OpenAPI schema but required in a successful - /// response; a missing field is a conversion failure (logged, asserted in - /// DEBUG, and thrown) rather than a silently-empty token. + /// The OpenAPI schema makes all three fields required, so missing or + /// malformed values represent server-side misbehavior rather than absence — + /// conversion fails loudly (logged, asserted in DEBUG, and thrown) rather + /// than silently producing an empty token or junk URL. internal init(from response: Components.Schemas.TokenResponse) throws(ConversionError) { - guard let apnsToken = response.apnsToken else { - try ConversionError.tokenMissingField(fieldName: "apnsToken").reportAndThrow() + guard let webcourierURL = URL(string: response.webcourierURL) else { + try ConversionError.tokenMissingField(fieldName: "webcourierURL").reportAndThrow() } - guard let webcAuthToken = response.webcAuthToken else { - try ConversionError.tokenMissingField(fieldName: "webcAuthToken").reportAndThrow() - } - self.init(apnsToken: apnsToken, webAuthToken: webcAuthToken) + self.init( + environment: APNsEnvironment(from: response.apnsEnvironment), + apnsToken: response.apnsToken, + webcourierURL: webcourierURL + ) } } diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 65c50b18..91921ed8 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -2232,27 +2232,55 @@ public enum Components { case tokens } } + /// Response body for `tokens/create`. Per Apple's archived REST reference, + /// the server returns the echoed environment, the minted APNs token, and a + /// long-poll URL that browser/Service-Worker callers use to receive push + /// notifications. Server-side callers typically only need `apnsToken`. + /// + /// /// - Remark: Generated from `#/components/schemas/TokenResponse`. public struct TokenResponse: Codable, Hashable, Sendable { + /// The APNs environment the token targets (echoes the request). + /// + /// - Remark: Generated from `#/components/schemas/TokenResponse/apnsEnvironment`. + @frozen public enum apnsEnvironmentPayload: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// The APNs environment the token targets (echoes the request). + /// + /// - Remark: Generated from `#/components/schemas/TokenResponse/apnsEnvironment`. + public var apnsEnvironment: Components.Schemas.TokenResponse.apnsEnvironmentPayload + /// The CloudKit-minted APNs token to use as a push destination. + /// /// - Remark: Generated from `#/components/schemas/TokenResponse/apnsToken`. - public var apnsToken: Swift.String? - /// - Remark: Generated from `#/components/schemas/TokenResponse/webcAuthToken`. - public var webcAuthToken: Swift.String? + public var apnsToken: Swift.String + /// Long-poll endpoint URL that browser / Service-Worker clients use to + /// receive push notifications. Not relevant for server callers, which + /// receive pushes via APNs proper. + /// + /// + /// - Remark: Generated from `#/components/schemas/TokenResponse/webcourierURL`. + public var webcourierURL: Swift.String /// Creates a new `TokenResponse`. /// /// - Parameters: - /// - apnsToken: - /// - webcAuthToken: + /// - apnsEnvironment: The APNs environment the token targets (echoes the request). + /// - apnsToken: The CloudKit-minted APNs token to use as a push destination. + /// - webcourierURL: Long-poll endpoint URL that browser / Service-Worker clients use to public init( - apnsToken: Swift.String? = nil, - webcAuthToken: Swift.String? = nil + apnsEnvironment: Components.Schemas.TokenResponse.apnsEnvironmentPayload, + apnsToken: Swift.String, + webcourierURL: Swift.String ) { + self.apnsEnvironment = apnsEnvironment self.apnsToken = apnsToken - self.webcAuthToken = webcAuthToken + self.webcourierURL = webcourierURL } public enum CodingKeys: String, CodingKey { + case apnsEnvironment case apnsToken - case webcAuthToken + case webcourierURL } } /// Per-record error returned inline in the `records` array of a 200 @@ -9449,12 +9477,12 @@ public enum Operations { case production = "production" } /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody/json/apnsEnvironment`. - public var apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload? + public var apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload /// Creates a new `jsonPayload`. /// /// - Parameters: /// - apnsEnvironment: - public init(apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload? = nil) { + public init(apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload) { self.apnsEnvironment = apnsEnvironment } public enum CodingKeys: String, CodingKey { @@ -9697,18 +9725,35 @@ public enum Operations { @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json`. public struct jsonPayload: Codable, Hashable, Sendable { + /// The APNs environment the token targets. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsEnvironment`. + @frozen public enum apnsEnvironmentPayload: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// The APNs environment the token targets. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsEnvironment`. + public var apnsEnvironment: Operations.registerToken.Input.Body.jsonPayload.apnsEnvironmentPayload /// The APNs token to register /// /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsToken`. - public var apnsToken: Swift.String? + public var apnsToken: Swift.String /// Creates a new `jsonPayload`. /// /// - Parameters: + /// - apnsEnvironment: The APNs environment the token targets. /// - apnsToken: The APNs token to register - public init(apnsToken: Swift.String? = nil) { + public init( + apnsEnvironment: Operations.registerToken.Input.Body.jsonPayload.apnsEnvironmentPayload, + apnsToken: Swift.String + ) { + self.apnsEnvironment = apnsEnvironment self.apnsToken = apnsToken } public enum CodingKeys: String, CodingKey { + case apnsEnvironment case apnsToken } } diff --git a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+SuccessCases.swift index bd0d39c2..dd0a4b00 100644 --- a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+SuccessCases.swift @@ -37,21 +37,28 @@ extension CloudKitServiceTests.Tokens { internal struct SuccessCases { private static let database: Database = .public(.prefers(.serverToServer)) - @Test("createAPNsToken() decodes apnsToken and renamed webcAuthToken") + @Test("createAPNsToken() decodes apnsEnvironment, apnsToken, webcourierURL") internal func createDecodesTokens() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("CloudKitService is not available on this operating system.") return } - let json = #"{ "apnsToken": "apns-123", "webcAuthToken": "web-456" }"# + let json = #""" + { + "apnsEnvironment": "development", + "apnsToken": "apns-123", + "webcourierURL": "https://webcourier.icloud.com/abc" + } + """# let service = try CloudKitServiceTests.Tokens.makeService(json: json) let result = try await service.createAPNsToken( environment: .development, database: Self.database ) + #expect(result.environment == .development) #expect(result.apnsToken == "apns-123") - #expect(result.webAuthToken == "web-456") + #expect(result.webcourierURL == URL(string: "https://webcourier.icloud.com/abc")) } @Test("registerAPNsToken() completes against an empty 200") @@ -61,7 +68,11 @@ extension CloudKitServiceTests.Tokens { return } let service = try CloudKitServiceTests.Tokens.makeService() - try await service.registerAPNsToken("abcdef0123456789", database: Self.database) + try await service.registerAPNsToken( + "abcdef0123456789", + environment: .development, + database: Self.database + ) } @Test("registerAPNsToken() rejects an empty token before dispatching") @@ -72,7 +83,11 @@ extension CloudKitServiceTests.Tokens { } let service = try CloudKitServiceTests.Tokens.makeService() await #expect(throws: CloudKitError.self) { - try await service.registerAPNsToken(" ", database: Self.database) + try await service.registerAPNsToken( + " ", + environment: .development, + database: Self.database + ) } } } diff --git a/Tests/MistKitTests/Models/Tokens/APNsTokenResultTests.swift b/Tests/MistKitTests/Models/Tokens/APNsTokenResultTests.swift index 22a97e55..915d8873 100644 --- a/Tests/MistKitTests/Models/Tokens/APNsTokenResultTests.swift +++ b/Tests/MistKitTests/Models/Tokens/APNsTokenResultTests.swift @@ -51,34 +51,43 @@ internal struct APNsTokenResultTests { ) } - @Test("maps TokenResponse fields, renaming webcAuthToken") + @Test("maps TokenResponse fields including environment and webcourierURL") internal func mapsFields() throws { let response = Components.Schemas.TokenResponse( + apnsEnvironment: .development, apnsToken: "apns-abc", - webcAuthToken: "web-xyz" + webcourierURL: "https://webcourier.icloud.com/abc" ) let result = try APNsTokenResult(from: response) + #expect(result.environment == .development) #expect(result.apnsToken == "apns-abc") - #expect(result.webAuthToken == "web-xyz") + #expect(result.webcourierURL == URL(string: "https://webcourier.icloud.com/abc")) } - @Test("missing apnsToken is a conversion failure") - internal func missingAPNsTokenThrows() throws { - let response = Components.Schemas.TokenResponse(webcAuthToken: "web-xyz") - expectThrow(ConversionError.tokenMissingField(fieldName: "apnsToken")) { - _ = try APNsTokenResult(from: response) - } + @Test("production environment round-trips") + internal func productionEnvironment() throws { + let response = Components.Schemas.TokenResponse( + apnsEnvironment: .production, + apnsToken: "apns-prod", + webcourierURL: "https://webcourier.icloud.com/prod" + ) + let result = try APNsTokenResult(from: response) + #expect(result.environment == .production) } - @Test("missing webcAuthToken is a conversion failure") - internal func missingWebAuthTokenThrows() throws { - let response = Components.Schemas.TokenResponse(apnsToken: "apns-abc") - expectThrow(ConversionError.tokenMissingField(fieldName: "webcAuthToken")) { + @Test("malformed webcourierURL is a conversion failure") + internal func malformedURLThrows() throws { + let response = Components.Schemas.TokenResponse( + apnsEnvironment: .development, + apnsToken: "apns-abc", + webcourierURL: "" + ) + expectThrow(ConversionError.tokenMissingField(fieldName: "webcourierURL")) { _ = try APNsTokenResult(from: response) } } - @Test("APNsEnvironment maps to the createToken payload") + @Test("APNsEnvironment maps to both request payloads and back from response") internal func environmentMapping() { #expect( Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload(from: .development) @@ -88,5 +97,15 @@ internal struct APNsTokenResultTests { Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload(from: .production) == .production ) + #expect( + Operations.registerToken.Input.Body.jsonPayload.apnsEnvironmentPayload(from: .development) + == .development + ) + #expect( + Operations.registerToken.Input.Body.jsonPayload.apnsEnvironmentPayload(from: .production) + == .production + ) + #expect(APNsEnvironment(from: .development) == .development) + #expect(APNsEnvironment(from: .production) == .production) } } diff --git a/openapi.yaml b/openapi.yaml index 1154063f..fe4a106e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -818,6 +818,8 @@ paths: application/json: schema: type: object + required: + - apnsEnvironment properties: apnsEnvironment: type: string @@ -851,7 +853,14 @@ paths: application/json: schema: type: object + required: + - apnsEnvironment + - apnsToken properties: + apnsEnvironment: + type: string + enum: [development, production] + description: The APNs environment the token targets. apnsToken: type: string description: The APNs token to register @@ -1439,11 +1448,30 @@ components: TokenResponse: type: object + description: | + Response body for `tokens/create`. Per Apple's archived REST reference, + the server returns the echoed environment, the minted APNs token, and a + long-poll URL that browser/Service-Worker callers use to receive push + notifications. Server-side callers typically only need `apnsToken`. + required: + - apnsEnvironment + - apnsToken + - webcourierURL properties: + apnsEnvironment: + type: string + enum: [development, production] + description: The APNs environment the token targets (echoes the request). apnsToken: type: string - webcAuthToken: + description: The CloudKit-minted APNs token to use as a push destination. + webcourierURL: type: string + format: uri + description: | + Long-poll endpoint URL that browser / Service-Worker clients use to + receive push notifications. Not relevant for server callers, which + receive pushes via APNs proper. RecordOperationFailure: type: object From e47a95de3df8259bd266e24bcc8534ea23e5b483 Mon Sep 17 00:00:00 2001 From: leogdion Date: Mon, 25 May 2026 11:31:39 -0400 Subject: [PATCH 04/14] Fix tokens/* 405 by routing under /device/ like CloudKit JS (#379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloudKit JS calls setApiModuleName("device") on token requests, producing POST /device/1/{container}/{environment}/tokens/{create,register}. The archived REST reference documents these under /database/... but the live service only routes OPTIONS there, hence the persistent 405 with Allow: OPTIONS. Switching to /device/ unblocks server-side calls. Also surfaces clientId on both request bodies (CloudKit JS always sends one, defaulting to a UUID) and exposes it as an optional caller-supplied parameter so callers can pin both halves of the round-trip to one logical client. Live test-private phase 16 (createToken+registerToken) now returns 200 with the expected {apnsToken, apnsEnvironment, webcourierURL} body — previously it failed at the first call with HTTP 405. Adds MistDemoLoggingBootstrap so --verbose on the integration commands turns on swift-log at .debug for com.brightdigit.MistKit.middleware, letting the existing LoggingMiddleware actually emit the wire trace that made this diagnosis possible. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/CreateTokenCommand.swift | 6 +- .../Commands/MistDemoLoggingBootstrap.swift | 71 ++++++++ .../Commands/RegisterTokenCommand.swift | 7 +- .../Commands/TestPrivateCommand.swift | 4 + .../Commands/TestPublicCommand.swift | 4 + .../Configuration/CreateTokenConfig.swift | 7 + .../Configuration/RegisterTokenConfig.swift | 7 + .../Phases/TokenRoundtripPhase.swift | 6 + .../MistDemoKit/Server/WebBackend.swift | 11 +- .../Server/WebRequests+Tokens.swift | 15 +- .../MistDemoKit/Server/WebServer+Tokens.swift | 2 + .../Server/MockBackend+Calls.swift | 2 + .../MistDemoTests/Server/MockBackend.swift | 9 +- .../Server/WebServerTests+Tokens.swift | 7 +- .../CloudKitService+TokenOperations.swift | 54 ++++-- Sources/MistKitOpenAPI/Client.swift | 27 ++- Sources/MistKitOpenAPI/Types.swift | 158 ++++++++++++------ openapi.yaml | 34 +++- 18 files changed, 348 insertions(+), 83 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift index 1a024850..d84d4799 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift @@ -44,15 +44,18 @@ public struct CreateTokenCommand: MistDemoCommand, OutputFormatting { CREATE-TOKEN - Create an APNs token for CloudKit subscriptions USAGE: - mistdemo create-token [--apns-environment ] + 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 @@ -68,6 +71,7 @@ public struct CreateTokenCommand: MistDemoCommand, OutputFormatting { 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) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift new file mode 100644 index 00000000..362723a4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift @@ -0,0 +1,71 @@ +// +// 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 { + /// 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 + } + } + + private static let hasBootstrapped = AtomicBool() +} + +/// Minimal atomic Bool — avoids pulling in Atomics for one flag. +private final class AtomicBool: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + /// Set to `newValue` and return the prior value. + 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/RegisterTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift index 0d5831d8..c2515abc 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift @@ -46,11 +46,15 @@ public struct RegisterTokenCommand: MistDemoCommand { REGISTER-TOKEN - Register a device APNs token with CloudKit USAGE: - mistdemo register-token --apns-token [--apns-environment ] + 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: @@ -75,6 +79,7 @@ public struct RegisterTokenCommand: MistDemoCommand { try await service.registerAPNsToken( apnsToken, environment: environment, + clientId: config.clientId, database: config.base.database ) print("✅ Registered APNs token with CloudKit.") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift index a3ebafe1..a86ad306 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -28,6 +28,7 @@ // internal import Foundation +internal import Logging internal import MistKit /// Command to run comprehensive integration tests against the private database, @@ -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 08255966..0aa801e7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift @@ -28,6 +28,7 @@ // internal import Foundation +internal import Logging internal import MistKit /// Command to run comprehensive integration tests for all CloudKit operations @@ -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/Configuration/CreateTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift index f7edc264..076f2d78 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift @@ -43,6 +43,10 @@ public struct CreateTokenConfig: Sendable, ConfigurationParseable { 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 @@ -51,11 +55,13 @@ public struct CreateTokenConfig: Sendable, ConfigurationParseable { 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 } @@ -85,6 +91,7 @@ public struct CreateTokenConfig: Sendable, ConfigurationParseable { 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/RegisterTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift index 100c2c87..3ddf574c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift @@ -45,6 +45,10 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { /// 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 @@ -53,11 +57,13 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { 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 } @@ -87,6 +93,7 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { 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/Integration/Phases/TokenRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift index 9205943c..9511b21a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift @@ -44,8 +44,13 @@ internal struct TokenRoundtripPhase: IntegrationPhase { 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 { @@ -55,6 +60,7 @@ internal struct TokenRoundtripPhase: IntegrationPhase { try await context.service.registerAPNsToken( token.apnsToken, environment: token.environment, + clientId: clientId, database: context.database ) print("✅ Created and registered an APNs token") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index 722086cc..9edefafc 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -89,12 +89,14 @@ internal protocol WebBackend: Sendable { 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 } @@ -196,19 +198,26 @@ extension CloudKitService: WebBackend { internal func webCreateToken( environment: APNsEnvironment, + clientId: String?, database: MistKit.Database ) async throws -> APNsTokenResult { - try await createAPNsToken(environment: environment, database: database) + 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 ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift index 0ea76243..92071996 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Tokens.swift @@ -34,14 +34,17 @@ extension WebRequests { /// `POST /api/tokens` — mint a CloudKit-managed APNs token. /// /// `apnsEnvironment` defaults to `development`; an unrecognized value falls - /// back to `development` rather than failing the demo request. + /// back to `development` rather than failing the demo request. `clientId` + /// is optional — when omitted the service generates a fresh UUID. internal struct CreateToken: Decodable { private enum CodingKeys: String, CodingKey { case apnsEnvironment + case clientId case database } internal let environment: APNsEnvironment + internal let clientId: String? internal let database: MistKit.Database internal init(from decoder: any Decoder) throws { @@ -50,6 +53,8 @@ extension WebRequests { try container.decodeIfPresent(String.self, forKey: .apnsEnvironment) ?? "development" self.environment = APNsEnvironment(rawValue: raw) ?? .development + self.clientId = + try container.decodeIfPresent(String.self, forKey: .clientId) self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) @@ -59,16 +64,20 @@ extension WebRequests { /// `POST /api/tokens/register` — register a device APNs token. /// /// `apnsEnvironment` defaults to `development`; an unrecognized value falls - /// back to `development` to keep the demo lenient. + /// back to `development` to keep the demo lenient. `clientId` is optional — + /// reuse the value passed to `/api/tokens` to tie both calls to one + /// logical client. internal struct RegisterToken: Decodable { private enum CodingKeys: String, CodingKey { case apnsToken case apnsEnvironment + case clientId case database } internal let apnsToken: String internal let environment: APNsEnvironment + internal let clientId: String? internal let database: MistKit.Database internal init(from decoder: any Decoder) throws { @@ -79,6 +88,8 @@ extension WebRequests { try container.decodeIfPresent(String.self, forKey: .apnsEnvironment) ?? "development" self.environment = APNsEnvironment(rawValue: raw) ?? .development + self.clientId = + try container.decodeIfPresent(String.self, forKey: .clientId) self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift index 9cd47995..876c9529 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Tokens.swift @@ -54,6 +54,7 @@ let backend = try backendFactory.make(token) let result = try await backend.webCreateToken( environment: body.environment, + clientId: body.clientId, database: body.database ) return try WebJSON.encoder().encode(WebResponse.Token(from: result)) @@ -72,6 +73,7 @@ try await backend.webRegisterToken( apnsToken: body.apnsToken, environment: body.environment, + clientId: body.clientId, database: body.database ) return try WebJSON.encoder().encode( diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index 8c92bdee..68503929 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -87,6 +87,7 @@ /// Captured arguments from the most recent `webCreateToken` call. internal struct CreateTokenCall: Sendable { internal let environment: APNsEnvironment + internal let clientId: String? internal let database: MistKit.Database } @@ -94,6 +95,7 @@ internal struct RegisterTokenCall: Sendable { internal let apnsToken: String internal let environment: APNsEnvironment + internal let clientId: String? internal let database: MistKit.Database } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index 96daecae..40c5e7ae 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -229,9 +229,14 @@ internal func webCreateToken( environment: APNsEnvironment, + clientId: String?, database: MistKit.Database ) async throws -> APNsTokenResult { - lastCreateToken = CreateTokenCall(environment: environment, database: database) + lastCreateToken = CreateTokenCall( + environment: environment, + clientId: clientId, + database: database + ) try consumePendingError() return APNsTokenResult( environment: environment, @@ -243,11 +248,13 @@ internal func webRegisterToken( apnsToken: String, environment: APNsEnvironment, + clientId: String?, database: MistKit.Database ) async throws { lastRegisterToken = RegisterTokenCall( apnsToken: apnsToken, environment: environment, + clientId: clientId, database: database ) try consumePendingError() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift index 6b2fb2fd..8359de02 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Tokens.swift @@ -48,7 +48,7 @@ internal func createToken() async throws { let fixture = Self.makeFixture(authenticated: true) let app = Application(router: try fixture.server.makeRouter()) - let jsonBody = #"{"apnsEnvironment":"production"}"# + let jsonBody = #"{"apnsEnvironment":"production","clientId":"web-panel-1"}"# try await app.test(.router) { client in try await client.execute( @@ -70,13 +70,15 @@ let captured = await fixture.backend.lastCreateToken #expect(captured?.environment == .production) + #expect(captured?.clientId == "web-panel-1") } @Test("POST /api/tokens/register forwards the token + environment to the backend") internal func registerToken() async throws { let fixture = Self.makeFixture(authenticated: true) let app = Application(router: try fixture.server.makeRouter()) - let jsonBody = #"{"apnsToken":"0a1b2c3d","apnsEnvironment":"production"}"# + let jsonBody = + #"{"apnsToken":"0a1b2c3d","apnsEnvironment":"production","clientId":"web-panel-1"}"# try await app.test(.router) { client in try await client.execute( @@ -92,6 +94,7 @@ let captured = await fixture.backend.lastRegisterToken #expect(captured?.apnsToken == "0a1b2c3d") #expect(captured?.environment == .production) + #expect(captured?.clientId == "web-panel-1") } @Test("token routes return 401 without a captured auth token") diff --git a/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift index e78bea6e..ad02bc2f 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+TokenOperations.swift @@ -31,6 +31,19 @@ internal import Foundation internal import MistKitOpenAPI extension CloudKitService { + private static func resolveClientId( + _ clientId: String? + ) throws(CloudKitError) -> String { + guard let clientId else { + return UUID().uuidString + } + let trimmed = clientId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw CloudKitError.badRequest(reason: "clientId must not be empty when provided") + } + return trimmed + } + /// Mint a CloudKit-managed APNs token for non-device callers. /// /// The native CloudKit framework doesn't need this — the OS binds the @@ -41,6 +54,12 @@ extension CloudKitService { /// /// - Parameters: /// - environment: The APNs environment the token targets. + /// - clientId: Logical CloudKit client identifier. CloudKit JS persists + /// this across sessions to tie repeat calls to one logical client + /// (used by the service for push de-dup). Pass the same value to + /// ``registerAPNsToken(_:environment:clientId:database:)`` to keep + /// both halves of the round-trip attributed to a single client. When + /// `nil`, a fresh UUID is generated per call with no continuity. /// - database: The CloudKit database whose credentials authenticate the /// request. The `tokens/create` endpoint is container-scoped — the /// database is not part of its path — but still needs credentials, so the @@ -48,11 +67,14 @@ extension CloudKitService { /// - Returns: The minted ``APNsTokenResult`` (`apnsToken`, echoed /// `environment`, and `webcourierURL` for browser/Service-Worker push /// delivery). - /// - Throws: ``CloudKitError`` if the request fails. + /// - Throws: ``CloudKitError/badRequest(reason:)`` if `clientId` is + /// provided but empty, or any error surfaced by the API. public func createAPNsToken( environment: APNsEnvironment, + clientId: String? = nil, database: Database ) async throws(CloudKitError) -> APNsTokenResult { + let resolvedClientId = try Self.resolveClientId(clientId) do { let client = try self.client(for: database) let response = try await client.createToken( @@ -62,7 +84,10 @@ extension CloudKitService { environment: self.environment ), body: .json( - .init(apnsEnvironment: .init(from: environment)) + .init( + apnsEnvironment: .init(from: environment), + clientId: resolvedClientId + ) ) ) ) @@ -79,34 +104,36 @@ extension CloudKitService { /// notifications to it. /// /// This is the device-side counterpart to - /// ``createAPNsToken(environment:database:)``: a real iOS/macOS device - /// registers with APNs the normal way, ships its hex token to your backend, - /// and the backend registers it here so CloudKit subscriptions in this - /// container deliver to that token. - /// - /// Per Apple's `RegisterTokens.html` REST reference, both `apnsEnvironment` - /// and `apnsToken` are required in the request body — the environment is not - /// inferred from the URL. + /// ``createAPNsToken(environment:clientId:database:)``: a real iOS/macOS + /// device registers with APNs the normal way, ships its hex token to your + /// backend, and the backend registers it here so CloudKit subscriptions in + /// this container deliver to that token. /// /// - Parameters: /// - apnsToken: The device's APNs token, as a hex string. /// - environment: The APNs environment the token targets (must match the /// environment under which the device registered with APNs). + /// - clientId: Logical CloudKit client identifier. Reuse the value passed + /// to ``createAPNsToken(environment:clientId:database:)`` to keep both + /// calls tied to the same logical client. When `nil`, a fresh UUID is + /// generated per call with no continuity to any prior `tokens/create`. /// - database: The CloudKit database whose credentials authenticate the /// request. The `tokens/register` endpoint is container-scoped — the /// database is not part of its path — but still needs credentials, so the /// caller picks which database's auth to present. - /// - Throws: ``CloudKitError/badRequest(reason:)`` if `apnsToken` is empty, or - /// any error surfaced by the API. + /// - Throws: ``CloudKitError/badRequest(reason:)`` if `apnsToken` is empty + /// or `clientId` is provided but empty, or any error surfaced by the API. public func registerAPNsToken( _ apnsToken: String, environment: APNsEnvironment, + clientId: String? = nil, database: Database ) async throws(CloudKitError) { let trimmed = apnsToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw CloudKitError.badRequest(reason: "apnsToken must not be empty") } + let resolvedClientId = try Self.resolveClientId(clientId) do { let client = try self.client(for: database) @@ -119,7 +146,8 @@ extension CloudKitService { body: .json( .init( apnsEnvironment: .init(from: environment), - apnsToken: trimmed + apnsToken: trimmed, + clientId: resolvedClientId ) ) ) diff --git a/Sources/MistKitOpenAPI/Client.swift b/Sources/MistKitOpenAPI/Client.swift index 2dcedb95..f308f80a 100644 --- a/Sources/MistKitOpenAPI/Client.swift +++ b/Sources/MistKitOpenAPI/Client.swift @@ -3389,17 +3389,24 @@ public struct Client: APIProtocol { } /// Create APNs Token /// - /// Create an Apple Push Notification service (APNs) token + /// Create an Apple Push Notification service (APNs) token. /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)`. + /// Lives under the `/device/` API module (not `/database/`). CloudKit's + /// archived REST reference documents this under `/database/...`, but the + /// live service routes only OPTIONS to that path and returns + /// `405 Method Not Allowed` for POST. The working path is the one + /// CloudKit JS uses (`setApiModuleName("device")`). + /// + /// + /// - Remark: HTTP `POST /device/{version}/{container}/{environment}/tokens/create`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/create/post(createToken)`. public func createToken(_ input: Operations.createToken.Input) async throws -> Operations.createToken.Output { try await client.send( input: input, forOperation: Operations.createToken.id, serializer: { input in let path = try converter.renderedPath( - template: "/database/{}/{}/{}/tokens/create", + template: "/device/{}/{}/{}/tokens/create", parameters: [ input.path.version, input.path.container, @@ -3508,17 +3515,21 @@ public struct Client: APIProtocol { } /// Register Token /// - /// Register a token for push notifications + /// Register an APNs device token for push notifications. + /// + /// Lives under the `/device/` API module (not `/database/`) — same + /// rationale as `tokens/create`. + /// /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)`. + /// - Remark: HTTP `POST /device/{version}/{container}/{environment}/tokens/register`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/register/post(registerToken)`. public func registerToken(_ input: Operations.registerToken.Input) async throws -> Operations.registerToken.Output { try await client.send( input: input, forOperation: Operations.registerToken.id, serializer: { input in let path = try converter.renderedPath( - template: "/database/{}/{}/{}/tokens/register", + template: "/device/{}/{}/{}/tokens/register", parameters: [ input.path.version, input.path.container, diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 91921ed8..b3d2f7f9 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -160,17 +160,28 @@ public protocol APIProtocol: Sendable { func uploadAssets(_ input: Operations.uploadAssets.Input) async throws -> Operations.uploadAssets.Output /// Create APNs Token /// - /// Create an Apple Push Notification service (APNs) token + /// Create an Apple Push Notification service (APNs) token. /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)`. + /// Lives under the `/device/` API module (not `/database/`). CloudKit's + /// archived REST reference documents this under `/database/...`, but the + /// live service routes only OPTIONS to that path and returns + /// `405 Method Not Allowed` for POST. The working path is the one + /// CloudKit JS uses (`setApiModuleName("device")`). + /// + /// + /// - Remark: HTTP `POST /device/{version}/{container}/{environment}/tokens/create`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/create/post(createToken)`. func createToken(_ input: Operations.createToken.Input) async throws -> Operations.createToken.Output /// Register Token /// - /// Register a token for push notifications + /// Register an APNs device token for push notifications. + /// + /// Lives under the `/device/` API module (not `/database/`) — same + /// rationale as `tokens/create`. /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)`. + /// + /// - Remark: HTTP `POST /device/{version}/{container}/{environment}/tokens/register`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/register/post(registerToken)`. func registerToken(_ input: Operations.registerToken.Input) async throws -> Operations.registerToken.Output } @@ -495,10 +506,17 @@ extension APIProtocol { } /// Create APNs Token /// - /// Create an Apple Push Notification service (APNs) token + /// Create an Apple Push Notification service (APNs) token. + /// + /// Lives under the `/device/` API module (not `/database/`). CloudKit's + /// archived REST reference documents this under `/database/...`, but the + /// live service routes only OPTIONS to that path and returns + /// `405 Method Not Allowed` for POST. The working path is the one + /// CloudKit JS uses (`setApiModuleName("device")`). /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)`. + /// + /// - Remark: HTTP `POST /device/{version}/{container}/{environment}/tokens/create`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/create/post(createToken)`. public func createToken( path: Operations.createToken.Input.Path, headers: Operations.createToken.Input.Headers = .init(), @@ -512,10 +530,14 @@ extension APIProtocol { } /// Register Token /// - /// Register a token for push notifications + /// Register an APNs device token for push notifications. + /// + /// Lives under the `/device/` API module (not `/database/`) — same + /// rationale as `tokens/create`. /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)`. + /// + /// - Remark: HTTP `POST /device/{version}/{container}/{environment}/tokens/register`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/register/post(registerToken)`. public func registerToken( path: Operations.registerToken.Input.Path, headers: Operations.registerToken.Input.Headers = .init(), @@ -9416,18 +9438,25 @@ public enum Operations { } /// Create APNs Token /// - /// Create an Apple Push Notification service (APNs) token + /// Create an Apple Push Notification service (APNs) token. + /// + /// Lives under the `/device/` API module (not `/database/`). CloudKit's + /// archived REST reference documents this under `/database/...`, but the + /// live service routes only OPTIONS to that path and returns + /// `405 Method Not Allowed` for POST. The working path is the one + /// CloudKit JS uses (`setApiModuleName("device")`). /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/create`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)`. + /// + /// - Remark: HTTP `POST /device/{version}/{container}/{environment}/tokens/create`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/create/post(createToken)`. public enum createToken { public static let id: Swift.String = "createToken" public struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/path`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/path`. public struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/path/version`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/path/version`. public var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/path/container`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/path/container`. public var container: Components.Parameters.container /// Container environment /// @@ -9436,7 +9465,7 @@ public enum Operations { case development = "development" case production = "production" } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/path/environment`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/path/environment`. public var environment: Components.Parameters.environment /// Creates a new `Path`. /// @@ -9455,7 +9484,7 @@ public enum Operations { } } public var path: Operations.createToken.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/header`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/header`. public struct Headers: Sendable, Hashable { public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. @@ -9467,29 +9496,43 @@ public enum Operations { } } public var headers: Operations.createToken.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody/json`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/requestBody/json`. public struct jsonPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody/json/apnsEnvironment`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/requestBody/json/apnsEnvironment`. @frozen public enum apnsEnvironmentPayload: String, Codable, Hashable, Sendable, CaseIterable { case development = "development" case production = "production" } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody/json/apnsEnvironment`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/requestBody/json/apnsEnvironment`. public var apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload + /// Logical CloudKit client identifier. CloudKit JS + /// persists this across sessions for push de-dup; for + /// server-side callers a fresh UUID per request is fine + /// unless continuity matters. + /// + /// + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/requestBody/json/clientId`. + public var clientId: Swift.String /// Creates a new `jsonPayload`. /// /// - Parameters: /// - apnsEnvironment: - public init(apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload) { + /// - clientId: Logical CloudKit client identifier. CloudKit JS + public init( + apnsEnvironment: Operations.createToken.Input.Body.jsonPayload.apnsEnvironmentPayload, + clientId: Swift.String + ) { self.apnsEnvironment = apnsEnvironment + self.clientId = clientId } public enum CodingKeys: String, CodingKey { case apnsEnvironment + case clientId } } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/requestBody/content/application\/json`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/requestBody/content/application\/json`. case json(Operations.createToken.Input.Body.jsonPayload) } public var body: Operations.createToken.Input.Body @@ -9511,9 +9554,9 @@ public enum Operations { } @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/responses/200/content`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/responses/200/content`. @frozen public enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/create/POST/responses/200/content/application\/json`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/create/POST/responses/200/content/application\/json`. case json(Components.Schemas.TokenResponse) /// The associated value of the enum case if `self` is `.json`. /// @@ -9540,7 +9583,7 @@ public enum Operations { } /// Token created successfully /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)/responses/200`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/create/post(createToken)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.createToken.Output.Ok) @@ -9578,7 +9621,7 @@ public enum Operations { /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) /// /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)/responses/400`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/create/post(createToken)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Components.Responses.Failure) @@ -9616,7 +9659,7 @@ public enum Operations { /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) /// /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/create/post(createToken)/responses/401`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/create/post(createToken)/responses/401`. /// /// HTTP response code: `401 unauthorized`. case unauthorized(Components.Responses.Failure) @@ -9670,18 +9713,22 @@ public enum Operations { } /// Register Token /// - /// Register a token for push notifications + /// Register an APNs device token for push notifications. /// - /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/tokens/register`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)`. + /// Lives under the `/device/` API module (not `/database/`) — same + /// rationale as `tokens/create`. + /// + /// + /// - Remark: HTTP `POST /device/{version}/{container}/{environment}/tokens/register`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/register/post(registerToken)`. public enum registerToken { public static let id: Swift.String = "registerToken" public struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/path`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/path`. public struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/path/version`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/path/version`. public var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/path/container`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/path/container`. public var container: Components.Parameters.container /// Container environment /// @@ -9690,7 +9737,7 @@ public enum Operations { case development = "development" case production = "production" } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/path/environment`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/path/environment`. public var environment: Components.Parameters.environment /// Creates a new `Path`. /// @@ -9709,7 +9756,7 @@ public enum Operations { } } public var path: Operations.registerToken.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/header`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/header`. public struct Headers: Sendable, Hashable { public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. @@ -9721,43 +9768,54 @@ public enum Operations { } } public var headers: Operations.registerToken.Input.Headers - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/requestBody/json`. public struct jsonPayload: Codable, Hashable, Sendable { /// The APNs environment the token targets. /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsEnvironment`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsEnvironment`. @frozen public enum apnsEnvironmentPayload: String, Codable, Hashable, Sendable, CaseIterable { case development = "development" case production = "production" } /// The APNs environment the token targets. /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsEnvironment`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsEnvironment`. public var apnsEnvironment: Operations.registerToken.Input.Body.jsonPayload.apnsEnvironmentPayload /// The APNs token to register /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsToken`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/apnsToken`. public var apnsToken: Swift.String + /// Logical CloudKit client identifier. Reuse the value + /// used with `tokens/create` to keep both calls tied to + /// the same logical client. + /// + /// + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/requestBody/json/clientId`. + public var clientId: Swift.String /// Creates a new `jsonPayload`. /// /// - Parameters: /// - apnsEnvironment: The APNs environment the token targets. /// - apnsToken: The APNs token to register + /// - clientId: Logical CloudKit client identifier. Reuse the value public init( apnsEnvironment: Operations.registerToken.Input.Body.jsonPayload.apnsEnvironmentPayload, - apnsToken: Swift.String + apnsToken: Swift.String, + clientId: Swift.String ) { self.apnsEnvironment = apnsEnvironment self.apnsToken = apnsToken + self.clientId = clientId } public enum CodingKeys: String, CodingKey { case apnsEnvironment case apnsToken + case clientId } } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/tokens/register/POST/requestBody/content/application\/json`. + /// - Remark: Generated from `#/paths/device/{version}/{container}/{environment}/tokens/register/POST/requestBody/content/application\/json`. case json(Operations.registerToken.Input.Body.jsonPayload) } public var body: Operations.registerToken.Input.Body @@ -9784,13 +9842,13 @@ public enum Operations { } /// Token registered successfully /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/200`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.registerToken.Output.Ok) /// Token registered successfully /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/200`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/200`. /// /// HTTP response code: `200 ok`. public static var ok: Self { @@ -9830,7 +9888,7 @@ public enum Operations { /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) /// /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/400`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Components.Responses.Failure) @@ -9868,7 +9926,7 @@ public enum Operations { /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) /// /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/401`. + /// - Remark: Generated from `#/paths//device/{version}/{container}/{environment}/tokens/register/post(registerToken)/responses/401`. /// /// HTTP response code: `401 unauthorized`. case unauthorized(Components.Responses.Failure) diff --git a/openapi.yaml b/openapi.yaml index fe4a106e..f7ca129a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -801,10 +801,17 @@ paths: '401': $ref: '#/components/responses/Failure' - /database/{version}/{container}/{environment}/tokens/create: + /device/{version}/{container}/{environment}/tokens/create: post: summary: Create APNs Token - description: Create an Apple Push Notification service (APNs) token + description: | + Create an Apple Push Notification service (APNs) token. + + Lives under the `/device/` API module (not `/database/`). CloudKit's + archived REST reference documents this under `/database/...`, but the + live service routes only OPTIONS to that path and returns + `405 Method Not Allowed` for POST. The working path is the one + CloudKit JS uses (`setApiModuleName("device")`). operationId: createToken tags: - Tokens @@ -820,10 +827,18 @@ paths: type: object required: - apnsEnvironment + - clientId properties: apnsEnvironment: type: string enum: [development, production] + clientId: + type: string + description: | + Logical CloudKit client identifier. CloudKit JS + persists this across sessions for push de-dup; for + server-side callers a fresh UUID per request is fine + unless continuity matters. responses: '200': description: Token created successfully @@ -836,10 +851,14 @@ paths: '401': $ref: '#/components/responses/Failure' - /database/{version}/{container}/{environment}/tokens/register: + /device/{version}/{container}/{environment}/tokens/register: post: summary: Register Token - description: Register a token for push notifications + description: | + Register an APNs device token for push notifications. + + Lives under the `/device/` API module (not `/database/`) — same + rationale as `tokens/create`. operationId: registerToken tags: - Tokens @@ -856,6 +875,7 @@ paths: required: - apnsEnvironment - apnsToken + - clientId properties: apnsEnvironment: type: string @@ -864,6 +884,12 @@ paths: apnsToken: type: string description: The APNs token to register + clientId: + type: string + description: | + Logical CloudKit client identifier. Reuse the value + used with `tokens/create` to keep both calls tied to + the same logical client. responses: '200': description: Token registered successfully From 22cc9edb7513a4ff28d5555c50589e0461e33d30 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 25 May 2026 13:39:36 -0400 Subject: [PATCH 05/14] Add web-courier notification reception probe + JS listener (#379) [skip ci] Close the loop on subscription notifications with a headless, receive-side test path and the matching web-app reception, using the verified CloudKit web-courier wire format. MistDemoKit (Swift): - WebCourierPoller: long-polls a webcourierURL on a dedicated URLSession; exposes pollOnce/waitForFrame plus a notifications() AsyncThrowingStream (the addNotificationListener mirror). No de-dup: the courier is consume-on-delivery and nid is shared across subscriptions for one change. - CourierFrame / CourierNotification: typed model mapping the {aps, ck} payload to the documented CloudKit.QueryNotification fields. - NotificationRoundtripPhase: create subscription (create/update/delete on Note) -> mint+register courier token -> trigger a record change -> await the push filtered by subscriptionID -> self-clean. Soft on non-arrival (delivery is eventual); registered in the private-DB pipeline. Web app (tokens.js): MistKit mode now long-polls the courier URL returned by /api/tokens and renders incoming notifications, mirroring CloudKit JS mode's registerForNotifications()/addNotificationListener. Docs: WEB_COURIER_SPIKE.md records the resolved wire format (request shape, {aps, ck} framing, consume-on-delivery / no-cursor). CLAUDE.md corrects the stale --config-file reference to the real Swift Configuration precedence. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 14 +- .../Phases/NotificationRoundtripPhase.swift | 208 ++++++++++++++++++ .../Tests/PrivateDatabaseTest.swift | 1 + .../Notifications/CourierFrame.swift | 73 ++++++ .../Notifications/CourierNotification.swift | 151 +++++++++++++ .../Notifications/WebCourierPoller.swift | 129 +++++++++++ .../MistDemoKit/Resources/js/tokens.js | 73 ++++++ Examples/MistDemo/WEB_COURIER_SPIKE.md | 182 +++++++++++++++ 8 files changed, 829 insertions(+), 2 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/NotificationRoundtripPhase.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift create mode 100644 Examples/MistDemo/WEB_COURIER_SPIKE.md diff --git a/CLAUDE.md b/CLAUDE.md index 6df25741..d1ffcd6a 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 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..23b1d3d7 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/NotificationRoundtripPhase.swift @@ -0,0 +1,208 @@ +// +// 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: IntegrationTestData.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. + private func trigger(suffix: String, context: PhaseContext) async throws -> RecordInfo { + let record = try await context.service.createRecord( + recordType: IntegrationTestData.recordType, + recordName: "mistkit-notif-\(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: IntegrationTestData.recordType, + recordName: recordName, + database: context.database + ) + } + try? await context.service.deleteSubscription( + id: subscriptionID, + database: context.database + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 44cddb23..e02119ce 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 { FinalVerificationPhase(), SubscriptionRoundtripPhase(), TokenRoundtripPhase(), + NotificationRoundtripPhase(), CleanupPhase(), ] } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift b/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift new file mode 100644 index 00000000..2c35693e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift @@ -0,0 +1,73 @@ +// +// CourierFrame.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 !os(WASI) + internal import Foundation + + /// A single raw response from a CloudKit web-courier long-poll. + /// + /// The web-courier wire format is **not** documented in Apple's CloudKit Web + /// Services REST reference — CloudKit JS consumes it internally — so this + /// frame preserves the unparsed bytes alongside a typed decode. See #379 / + /// `WEB_COURIER_SPIKE.md` for the verified payload shape. + internal struct CourierFrame: Sendable { + /// HTTP status returned by the courier endpoint, when available. + internal let statusCode: Int? + /// The raw response body, preserved verbatim for wire-format discovery. + internal let raw: Data + + /// The body decoded as UTF-8 text, for logging during the discovery spike. + internal var bodyText: String { + String(decoding: raw, as: UTF8.self) + } + + /// Best-effort JSON decode of `raw`; `nil` when the body isn't JSON. + /// Computed (not stored) so the frame stays `Sendable` — `Any` isn't. + internal var json: Any? { + try? JSONSerialization.jsonObject(with: raw) + } + + /// Whether the frame carries a payload worth inspecting. A long-poll that + /// returns empty (a server-side keepalive / timeout) is not a delivery. + internal var isEmpty: Bool { + raw.isEmpty + } + + /// The frame decoded into a typed ``CourierNotification``, or `nil` if the + /// body isn't a recognizable notification (e.g. a keepalive). + internal var notification: CourierNotification? { + try? CourierNotification(data: raw) + } + + internal init(statusCode: Int?, raw: Data) { + self.statusCode = statusCode + self.raw = raw + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift b/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift new file mode 100644 index 00000000..53b405c7 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift @@ -0,0 +1,151 @@ +// +// CourierNotification.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 + +/// A CloudKit notification decoded from a web-courier frame. +/// +/// Mirrors the documented `CloudKit.QueryNotification` (cloudkitjs.md), mapped +/// from the verified courier wire payload (#379): +/// +/// ```jsonc +/// { "aps": { "alert": "…" }, +/// "ck": { "nid": "…", "cid": "…", +/// "qry": { "sid": "…", "rid": "…", "fo": 1, "zid": "…", "dbs": 1 } } } +/// ``` +/// +/// Only query-notification fields are mapped today (the demo creates query +/// subscriptions). Zone notifications would surface a sibling `ck` block; add +/// them here when needed. +internal struct CourierNotification: Sendable { + /// The event that fired the subscription — the `ck.qry.fo` code. + internal enum Reason: Int, Sendable { + case recordCreated = 1 + case recordUpdated = 2 + case recordDeleted = 3 + } + + /// `ck.nid` — unique id for this notification. + internal let notificationID: String? + /// `ck.cid` — the container that generated the notification. + internal let containerIdentifier: String? + /// `ck.qry.sid` — the subscription that fired. + internal let subscriptionID: String? + /// `ck.qry.rid` — the record that changed. + internal let recordName: String? + /// `ck.qry.zid` — the zone the record lives in. + internal let zoneID: String? + /// `ck.qry.fo` — why the subscription fired. + internal let reason: Reason? + /// `ck.qry.dbs` — database-scope code (kept raw; interpretation unconfirmed). + internal let databaseScope: Int? + /// `aps.alert` — the alert text, when present. + internal let alertBody: String? + + /// Decode a notification from a raw courier frame body. + internal init(data: Data) throws { + let wire = try JSONDecoder().decode(Wire.self, from: data) + let cloudKit = wire.cloudKit + let query = cloudKit?.qry + self.notificationID = cloudKit?.nid + self.containerIdentifier = cloudKit?.cid + self.subscriptionID = query?.sid + self.recordName = query?.rid + self.zoneID = query?.zid + self.reason = query?.firesOn.flatMap(Reason.init(rawValue:)) + self.databaseScope = query?.dbs + self.alertBody = wire.aps?.alert?.body + } +} + +extension CourierNotification { + /// The on-the-wire courier payload shape; mapped into the flat + /// ``CourierNotification`` by ``init(data:)``. + private struct Wire: Decodable { + fileprivate struct APS: Decodable { + fileprivate let alert: Alert? + } + + /// APNs `alert` is either a bare string or an object with a `body` key. + fileprivate enum Alert: Decodable { + case text(String) + case structured(body: String?) + + private enum CodingKeys: String, CodingKey { + case body + } + + fileprivate var body: String? { + switch self { + case .text(let value): return value + case .structured(let body): return body + } + } + + fileprivate init(from decoder: any Decoder) throws { + if let value = try? decoder.singleValueContainer().decode(String.self) { + self = .text(value) + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + self = .structured(body: try container.decodeIfPresent(String.self, forKey: .body)) + } + } + + fileprivate struct CloudKitPayload: Decodable { + fileprivate struct Qry: Decodable { + fileprivate let sid: String? + fileprivate let rid: String? + fileprivate let zid: String? + fileprivate let firesOn: Int? + fileprivate let dbs: Int? + + private enum CodingKeys: String, CodingKey { + case sid + case rid + case zid + case dbs + case firesOn = "fo" + } + } + + fileprivate let nid: String? + fileprivate let cid: String? + fileprivate let qry: Qry? + } + + fileprivate let aps: APS? + fileprivate let cloudKit: CloudKitPayload? + + private enum CodingKeys: String, CodingKey { + case aps + case cloudKit = "ck" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift b/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift new file mode 100644 index 00000000..7943d5e9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift @@ -0,0 +1,129 @@ +// +// WebCourierPoller.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 !os(WASI) + internal import Foundation + + #if canImport(FoundationNetworking) + internal import FoundationNetworking + #endif + + /// Long-polls a CloudKit `webcourierURL` to receive subscription-triggered + /// notifications without a device or APNs entitlement — the only fully + /// headless way to observe a push end-to-end. + /// + /// - Important: This uses a **dedicated** ephemeral `URLSession`, never the + /// CloudKit API `ClientTransport`. The courier host is distinct from + /// `api.apple-cloudkit.com`; sharing an HTTP/2 connection across the two + /// risks 421 Misdirected Request, the same hazard called out for asset + /// uploads in CLAUDE.md. + internal struct WebCourierPoller { + private let courierURL: URL + private let perPollTimeout: TimeInterval + private let session: URLSession + + /// - Parameters: + /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. + /// - perPollTimeout: How long a single long-poll request waits before + /// the server (or this client) gives up and the caller polls again. + internal init(courierURL: URL, perPollTimeout: TimeInterval = 30) { + self.courierURL = courierURL + self.perPollTimeout = perPollTimeout + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = perPollTimeout + 5 + configuration.waitsForConnectivity = false + self.session = URLSession(configuration: configuration) + } + + /// Issue one long-poll request and return whatever the courier responds + /// with. Returns even on an empty/keepalive body so the caller can decide + /// whether to poll again. + internal func pollOnce() async throws -> CourierFrame { + var request = URLRequest(url: courierURL) + request.httpMethod = "GET" + request.timeoutInterval = perPollTimeout + let (data, response) = try await session.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode + return CourierFrame(statusCode: statusCode, raw: data) + } + + /// Long-poll repeatedly until a non-empty frame arrives or the task is + /// cancelled. The caller is expected to wrap this in a bounded timeout + /// (e.g. ``withTimeout(seconds:operation:)``) so a never-arriving push + /// can't hang the run. + internal func waitForFrame() async throws -> CourierFrame { + while true { + try Task.checkCancellation() + let frame = try await pollOnce() + if !frame.isEmpty { + return frame + } + // Empty body = keepalive/timeout; brief backoff before re-polling. + try await Task.sleep(for: .milliseconds(250)) + } + } + + /// A stream of decoded notifications — the Swift mirror of CloudKit JS's + /// `addNotificationListener`. Re-polls forever and yields each decoded + /// frame. Finishes when the consuming task is cancelled (e.g. via + /// `withTimeout`) or rethrows a transport error. + /// + /// No de-duplication: the courier is **consume-on-delivery** — each poll + /// pops exactly one queued notification and never redelivers it (verified + /// #379). De-duping on `notificationID` would be actively wrong: one change + /// matching N subscriptions enqueues N notifications that **share** a `nid` + /// but differ by `sid`, so keying on `nid` would drop the siblings. + internal func notifications() -> AsyncThrowingStream { + // Capture only Sendable state; rebuild the poller inside the task so the + // non-Sendable URLSession never crosses the concurrency boundary. + let courierURL = self.courierURL + let perPollTimeout = self.perPollTimeout + return AsyncThrowingStream { continuation in + let task = Task { + let poller = WebCourierPoller(courierURL: courierURL, perPollTimeout: perPollTimeout) + do { + while !Task.isCancelled { + let frame = try await poller.pollOnce() + guard let notification = frame.notification else { + // Keepalive/unparseable; brief backoff, then re-poll. + try await Task.sleep(for: .milliseconds(250)) + continue + } + continuation.yield(notification) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js index 26723981..de697b6a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js @@ -8,6 +8,69 @@ 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 @@ -33,6 +96,14 @@ document.getElementById('tokens-register-btn').addEventListener('click', async ( 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'); } @@ -41,6 +112,8 @@ document.getElementById('tokens-register-btn').addEventListener('click', async ( 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'); diff --git a/Examples/MistDemo/WEB_COURIER_SPIKE.md b/Examples/MistDemo/WEB_COURIER_SPIKE.md new file mode 100644 index 00000000..f0d64b61 --- /dev/null +++ b/Examples/MistDemo/WEB_COURIER_SPIKE.md @@ -0,0 +1,182 @@ +# Web-Courier Wire-Format Spike (#379) + +## Goal + +Capture the **CloudKit web-courier long-poll protocol** by observing CloudKit +JS do it in a browser, so we can replicate it in: + +- `WebCourierPoller` (Swift) — the headless integration-test receiver, and +- a `fetch` loop in `tokens.js` — MistKit-mode reception parity in the web app. + +Apple documents the *parsed* `CloudKit.Notification` object but **not** the +courier transport. This spike fills that gap. Three unknowns to resolve: + +1. **Request shape** — the exact URL + query params + headers CloudKit JS sends + on each courier `GET`. +2. **Response framing** — the body shape when a notification is delivered vs. + an empty keepalive/timeout, and the HTTP status in each case. +3. **The cursor** — how the *next* poll differs from the first so the courier + doesn't redeliver (a token/marker in the URL, a header, or the body). + +> Why observe CloudKit JS instead of blind-polling: the web app already wires +> `registerForNotifications()` + `addNotificationListener` in CloudKit JS mode +> (`Sources/MistDemoKit/Resources/js/tokens.js:42-55`). Watching it on the wire +> shows the real request params and cursor handling — far more reliable than +> guessing. + +## Findings so far + +**Unknown #1 (request shape) — SOLVED.** `tokens/create` returns a +self-contained courier URL; you `GET` it verbatim, no extra params/headers: + +``` +https://webcourier.sandbox.push.apple.com:443/aps?tok=&ttl=43200 +``` + +- Host is **APNs' web-courier** (Safari-push long-poll infra), *not* a CloudKit + host. `.sandbox.` = development; production is `webcourier.push.apple.com`. +- `tok` = the `apnsToken` verbatim; `ttl=43200` = token valid 12h (a long-lived + poller must re-mint before expiry). +- Confirms `WebCourierPoller.pollOnce()`'s design (GET the URL as-is). + +> ⚠️ The `apnsToken` and the browser's web-auth session cookie are **live +> secrets** — never paste the literal values into this file, commits, or issues. + +**Unknown #2 (framing) — SOLVED.** HTTP 200 with a single JSON object per poll: + +```jsonc +{ "aps": { "alert": "…" }, // OPTIONAL — only when the subscription set an alert + "ck": { "nid": "…", "cid": "…", // notificationID, containerIdentifier + "ckuserid": "…", "ce": 2, // caller user id; "ce" = protocol/env code (unconfirmed) + "qry": { "sid": "…", // subscriptionID + "rid": "…", // recordName + "fo": 1, // fires-on: 1=created 2=updated 3=deleted + "zid": "…", "dbs": 1, "zoid": "…" } } } +``` + +**Unknown #3 (cursor) — SOLVED: there is no cursor.** The courier is a +**consume-on-delivery FIFO queue**. Each `GET` pops exactly **one** queued +notification; when the queue is empty the request long-polls (hangs) until a new +push arrives. No ack/marker/`since` param is involved — re-`GET`ting the same URL +just drains the next item. + +> ⚠️ `nid` is **not** unique per delivery. One change matching N subscriptions +> enqueues N notifications that **share a `nid`** but differ by `sid` (verified: +> two back-to-back polls returned the same `nid`/`rid` with different `sid`s). +> So consumers must **not** de-dup on `nid` — `WebCourierPoller.notifications()` +> therefore does no de-duplication. + +> NOTE: the courier `GET` only appears in **CloudKit JS mode** (in MistKit mode +> the browser doesn't poll it — that's the reception gap). Use CloudKit JS mode +> for the DevTools route, or the direct-`curl` route which sidesteps the browser. + +### Fast path: long-poll the courier directly + +Precondition: a `Note` subscription exists (create/update/delete) **and** this +token is registered (the MistKit-mode tokens panel does create+register). + +```bash +# Terminal 1 — hangs until a push arrives, then prints the frame: +curl -N 'https://webcourier.sandbox.push.apple.com:443/aps?tok=&ttl=43200' + +# Terminal 2 — fire the subscription: +swift run mistdemo create # defaults to a Note +``` + +Re-run Terminal 1 after a delivery to see whether the next poll differs (cursor). +Expect an APNs payload: an `aps` dict + a `ck` dict carrying the CloudKit +notification (`subscriptionID`, record name, fire reason). + +## Prerequisites + +- CloudKit credentials in `Examples/MistDemo/.env` (see `CLAUDE.md` → MistDemo + Configuration). For the web app you need at minimum: + ``` + CLOUDKIT_CONTAINER_ID=iCloud.com.yourorg.yourapp + CLOUDKIT_ENVIRONMENT=development + CLOUDKIT_API_TOKEN=… + CLOUDKIT_WEB_AUTH_TOKEN=… + ``` +- A record type to subscribe to (the demo uses `Note`). +- A browser with DevTools (Chrome/Safari/Firefox all fine). + +## Procedure + +1. **Start the web app** from `Examples/MistDemo`: + ```bash + swift run mistdemo web # serves http://localhost:8080 by default + ``` + Open the page and **switch the mode toggle to "CloudKit JS"** (this routes + the panels through Apple's SDK in the browser, not through `/api/*`). + +2. **Authenticate** via the auth panel (CloudKit JS `setUpAuth` / sign-in). + +3. **Create a query subscription** in the subscriptions panel: + - record type: `Note` + - fires on: **create, update, delete** — i.e. *any* change to a `Note`. + - note the `subscriptionID` it returns. + - For the capture itself, a **create** is the simplest single action to fire + it (step 6); once the wire format is confirmed, the same subscription also + delivers on updates and deletes. + +4. **Open DevTools → Network tab.** Then: + - Enable **"Preserve log"** (long-polls reopen repeatedly; without this the + entries get cleared and you lose the cursor progression). + - Filter to the CloudKit host (type `cloudkit` or `icloud` in the filter). + +5. **Click "Register for notifications"** in the tokens panel. In the Network + tab you should now see, in order: + - a `POST …/tokens/create` (returns `apnsToken` + `webcourierURL`), and + - the **first courier `GET`** against that `webcourierURL`, left *pending* + (this is the long-poll holding open). + +6. **Trigger a notification.** In a second browser tab, or from the CLI: + ```bash + swift run mistdemo create # creates a Note → fires the subscription + ``` + Within a few seconds the pending courier `GET` should **resolve with a body**, + and CloudKit JS should immediately open the **next** courier `GET`. + +7. **Capture three requests** from the Network tab (right-click → "Copy as + cURL", or export the whole session as **HAR**): + - the `tokens/create` POST (request body + response), + - the **first** courier `GET` (the empty/keepalive one, if any), and + - the courier `GET` that **delivered** the notification, plus the **next** + `GET` after it (to see the cursor advance). + +## What to record (paste into issue #379) + +For each courier `GET`: + +``` +URL (full, incl. query params): ______________________________________________ +Method / headers of interest: ______________________________________________ +HTTP status: ______________________________________________ +Response body (verbatim): ______________________________________________ +``` + +Then answer the three unknowns: + +- [ ] **Request shape** — what query params/headers identify the token? Is the + `apnsToken` in the URL, a header, or implicit via cookie/session? +- [ ] **Response framing** — JSON object? array of notifications? What does an + *empty* poll (timeout/keepalive) return — empty body? `204`? `{}`? +- [ ] **Cursor** — what changes between the delivering `GET` and the next one? + (a `?…token=` / `?…ck=` param, a sequence number in the prior body, etc.) +- [ ] **Notification body → documented fields** — confirm the wire body maps to + `subscriptionID`, `notificationType`, `queryNotificationReason`, + `recordName`, `zoneID` (the `CloudKit.Notification` fields). + +## How findings feed the code + +| Finding | Lands in | +|---|---| +| Request URL/params/headers | `WebCourierPoller.pollOnce()` request construction | +| Response framing + empty-poll detection | `CourierFrame` parsing + `waitForFrame()` empty check | +| Cursor handling | new `nextURL`/`cursor` plumbing in `WebCourierPoller` loop | +| Notification body shape | a `Decodable` `CourierNotification` model (mirrors `CloudKit.Notification`) | +| Confirmation it's browser-reachable | greenlights the `tokens.js` MistKit-mode `fetch` loop (no server proxy) | + +Once the cursor + framing are known, `WebCourierPoller` can graduate from a raw +frame probe into a real notification stream, and the same parsing drops into the +web app's JS. From 7797d6649f4bd319627ed7f6116a71339b205390 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 25 May 2026 14:07:25 -0400 Subject: [PATCH 06/14] Add CourierNotification decoding tests for verified payloads (#379) [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the three real web-courier payload shapes captured against a live container — an alerting push, a bare ck-only frame, and a silent content-available push — plus the fo→reason mapping, as a decoding regression for CourierNotification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CourierNotificationTests.swift | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift diff --git a/Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift new file mode 100644 index 00000000..af94a9f3 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift @@ -0,0 +1,123 @@ +// +// CourierNotificationTests.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 Testing + +@testable import MistDemoKit + +/// Decoding regression tests pinned to the three real web-courier payload +/// shapes captured against a live container (#379). One record change matching +/// three subscriptions produced three frames sharing a `nid` but differing by +/// `sid` and `aps` shape: an alerting push, a bare `ck`-only frame, and a +/// silent `content-available` push. +@Suite("CourierNotification") +internal struct CourierNotificationTests { + /// `aps.alert` carries a string body; all query fields present. + @Test("Decodes an alerting push") + internal func decodesAlertingPush() throws { + let body = #""" + {"aps":{"alert":"MistDemo push (token suffix: 429bd7)"}, + "ck":{"ce":2,"ckuserid":"_aca0fa","aux":{}, + "nid":"c17cf8c1-5567-4635-b650-9aba0cd9acdc", + "qry":{"zid":"_defaultZone","dbs":1,"fo":1,"zoid":"_aca0fa", + "rid":"B61C8113-78DD-4ED1-9E62-4666F9888FE9", + "sid":"MistDemo.pushToken"}, + "cid":"iCloud.com.brightdigit.MistDemo"}} + """# + + let notification = try CourierNotification(data: Data(body.utf8)) + + #expect(notification.notificationID == "c17cf8c1-5567-4635-b650-9aba0cd9acdc") + #expect(notification.containerIdentifier == "iCloud.com.brightdigit.MistDemo") + #expect(notification.subscriptionID == "MistDemo.pushToken") + #expect(notification.recordName == "B61C8113-78DD-4ED1-9E62-4666F9888FE9") + #expect(notification.zoneID == "_defaultZone") + #expect(notification.reason == .recordCreated) + #expect(notification.databaseScope == 1) + #expect(notification.alertBody == "MistDemo push (token suffix: 429bd7)") + } + + /// No `aps` block at all — a sibling subscription's frame for the same change. + @Test("Decodes a frame with no aps block") + internal func decodesFrameWithoutAps() throws { + let body = #""" + {"ck":{"ce":2,"ckuserid":"_aca0fa", + "nid":"c17cf8c1-5567-4635-b650-9aba0cd9acdc", + "qry":{"zid":"_defaultZone","dbs":1,"fo":1,"zoid":"_aca0fa", + "rid":"B61C8113-78DD-4ED1-9E62-4666F9888FE9", + "sid":"7D5FBF2F-A449-4EDA-9287-F9B8EBFE0DD3"}, + "cid":"iCloud.com.brightdigit.MistDemo"}} + """# + + let notification = try CourierNotification(data: Data(body.utf8)) + + #expect(notification.subscriptionID == "7D5FBF2F-A449-4EDA-9287-F9B8EBFE0DD3") + #expect(notification.recordName == "B61C8113-78DD-4ED1-9E62-4666F9888FE9") + #expect(notification.reason == .recordCreated) + #expect(notification.alertBody == nil) + } + + /// Silent/background push: `aps` is `{"content-available":1}` with no + /// `alert` key. The decoder must not choke on the missing alert. + @Test("Decodes a silent content-available push") + internal func decodesSilentPush() throws { + let body = #""" + {"aps":{"content-available":1}, + "ck":{"ce":2,"ckuserid":"_aca0fa","aux":{}, + "nid":"c17cf8c1-5567-4635-b650-9aba0cd9acdc", + "qry":{"zid":"_defaultZone","dbs":1,"fo":1,"zoid":"_aca0fa", + "rid":"B61C8113-78DD-4ED1-9E62-4666F9888FE9", + "sid":"MistDemo.noteCreated"}, + "cid":"iCloud.com.brightdigit.MistDemo"}} + """# + + let notification = try CourierNotification(data: Data(body.utf8)) + + #expect(notification.subscriptionID == "MistDemo.noteCreated") + #expect(notification.recordName == "B61C8113-78DD-4ED1-9E62-4666F9888FE9") + #expect(notification.reason == .recordCreated) + #expect(notification.alertBody == nil) + } + + /// `fo` maps to the documented query-notification reasons. + @Test( + "Maps the fires-on reason code", + arguments: [ + (1, CourierNotification.Reason.recordCreated), + (2, .recordUpdated), + (3, .recordDeleted), + ] + ) + internal func mapsReason(code: Int, expected: CourierNotification.Reason) throws { + let body = #"{"ck":{"qry":{"fo":\#(code),"sid":"s","rid":"r"}}}"# + let notification = try CourierNotification(data: Data(body.utf8)) + #expect(notification.reason == expected) + } +} From 5b2f243496b7156481b027373c6182633fbe1909 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 25 May 2026 14:07:25 -0400 Subject: [PATCH 07/14] Surface per-subscription modify failures via SubscriptionResult (#379) [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modifySubscriptions silently dropped CloudKit's inline per-subscription errors (e.g. INTERNAL_ERROR "could not find subscription we just created"), returning an empty array where CloudKit JS shows a visible failure. Apply the records' RecordResult success-or-failure pattern to subscriptions so failures are surfaced consistently across the API. - openapi.yaml: add SubscriptionOperationFailure; make SubscriptionsModifyResponse items oneOf:[SubscriptionOperationFailure, Subscription] (mirrors ModifyResponse). Regenerated MistKitOpenAPI. - Hoist the server-error-code enum to a shared CloudKitServerErrorCode (RecordOperationFailure.ServerErrorCode kept as a typealias) so record and subscription failures share one code type. - Add SubscriptionOperationFailure + SubscriptionResult (.success/.failure/.get()) and CloudKitError.subscriptionOperationFailed. - modifySubscriptions now returns [SubscriptionResult] (surfaces failures, still skips deletion acks); createSubscription throws on failure; deleteSubscription surfaces a failed delete. Web demo maps .get() so the panel shows the error. Note: this surfaces the failure; it does not make the create succeed — that CloudKit-side cause (likely a non-queryable record type) is unchanged. Breaking: modifySubscriptions return type [SubscriptionInfo] -> [SubscriptionResult]. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MistDemoKit/Server/WebBackend.swift | 6 +- .../CloudKitService/CloudKitError.swift | 14 +- .../CloudKitService+ModifySubscriptions.swift | 39 +++-- .../Models/CloudKitServerErrorCode.swift | 98 +++++++++++ .../Models/RecordOperationFailure.swift | 68 +------- .../Models/SubscriptionOperationFailure.swift | 102 ++++++++++++ .../MistKit/Models/SubscriptionResult.swift | 68 ++++++++ Sources/MistKitOpenAPI/Types.swift | 125 +++++++++++++- ...viceTests.Subscriptions+FailureCases.swift | 152 ++++++++++++++++++ openapi.yaml | 49 +++++- 10 files changed, 639 insertions(+), 82 deletions(-) create mode 100644 Sources/MistKit/Models/CloudKitServerErrorCode.swift create mode 100644 Sources/MistKit/Models/SubscriptionOperationFailure.swift create mode 100644 Sources/MistKit/Models/SubscriptionResult.swift create mode 100644 Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index 9edefafc..4e029168 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -193,7 +193,11 @@ extension CloudKitService: WebBackend { operations: [SubscriptionOperation], database: MistKit.Database ) async throws -> [SubscriptionInfo] { - try await modifySubscriptions(operations, database: database) + 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( diff --git a/Sources/MistKit/CloudKitService/CloudKitError.swift b/Sources/MistKit/CloudKitService/CloudKitError.swift index edc3a8b3..40ecc80e 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError.swift @@ -63,6 +63,10 @@ public enum CloudKitError: LocalizedError, Sendable { /// back as a `RecordOperationFailure`, surfaced by a single-record /// convenience (`createRecord`/`updateRecord`/`deleteRecord`). case recordOperationFailed(RecordOperationFailure) + /// A per-subscription operation in a `modifySubscriptions` batch came back as + /// a `SubscriptionOperationFailure`, surfaced by the single-subscription + /// convenience (`createSubscription`). + case subscriptionOperationFailed(SubscriptionOperationFailure) case underlyingError(any Error) case decodingError(DecodingError) case networkError(URLError) @@ -91,7 +95,7 @@ public enum CloudKitError: LocalizedError, Sendable { case .badRequest, .atomicFailure: return 400 case .invalidResponse, .conversionFailed, .recordOperationFailed, - .underlyingError, .decodingError, .networkError, + .subscriptionOperationFailed, .underlyingError, .decodingError, .networkError, .unsupportedOperationType, .paginationLimitExceeded, .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey: return nil @@ -127,6 +131,14 @@ public enum CloudKitError: LocalizedError, Sendable { message += "\nReason: \(reason)" } return message + case .subscriptionOperationFailed(let subscriptionError): + var message = + "CloudKit subscription operation failed for '\(subscriptionError.subscriptionID)' " + + "(\(subscriptionError.serverErrorCode.rawValue))" + if let reason = subscriptionError.reason { + message += "\nReason: \(reason)" + } + return message case .underlyingError(let error): return "CloudKit operation failed with underlying error: \(String(reflecting: error))" case .decodingError(let error): diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift index 73cce5d1..4525ba9a 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift @@ -41,7 +41,7 @@ extension CloudKitService { public func modifySubscriptions( _ operations: [SubscriptionOperation], database: Database - ) async throws(CloudKitError) -> [SubscriptionInfo] { + ) async throws(CloudKitError) -> [SubscriptionResult] { do { let client = try self.client(for: database) let response = try await client.modifySubscriptions( @@ -64,15 +64,22 @@ extension CloudKitService { let subscriptionsData: Components.Schemas.SubscriptionsModifyResponse = try await responseProcessor.processModifySubscriptionsResponse(response) return try (subscriptionsData.subscriptions ?? []).compactMap { - subscription -> SubscriptionInfo? in - // CloudKit echoes a deleted subscription as a bare `{ subscriptionID }` - // with no `subscriptionType`/`query`/`firesOn`. That's a deletion - // acknowledgement, not a subscription — skip it rather than treating - // the missing type as a conversion failure. - guard subscription.subscriptionType != nil else { - return nil + item -> SubscriptionResult? in + switch item { + case .SubscriptionOperationFailure(let failure): + // A per-subscription error CloudKit returned inline in the 200 body + // (e.g. INTERNAL_ERROR on create). Surface it rather than dropping it. + return .failure(SubscriptionOperationFailure(from: failure)) + case .Subscription(let subscription): + // CloudKit echoes a deleted subscription as a bare `{ subscriptionID }` + // with no `subscriptionType`/`query`/`firesOn`. That's a deletion + // acknowledgement, not a result — skip it rather than treating the + // missing type as a conversion failure. + guard subscription.subscriptionType != nil else { + return nil + } + return .success(try SubscriptionInfo(from: subscription)) } - return try SubscriptionInfo(from: subscription) } } catch { throw mapToCloudKitError(error, context: "modifySubscriptions") @@ -89,7 +96,10 @@ extension CloudKitService { /// - subscription: The subscription to create. /// - database: The CloudKit database scope to modify. /// - Returns: The created subscription as returned by CloudKit. - /// - Throws: ``CloudKitError`` if the request fails or the response is empty. + /// - Throws: ``CloudKitError/subscriptionOperationFailed(_:)`` if CloudKit + /// rejected the create (e.g. `INTERNAL_ERROR`), ``CloudKitError`` if the + /// request fails, or ``CloudKitError/invalidResponse`` if the response is + /// empty. @discardableResult public func createSubscription( _ subscription: SubscriptionInfo, @@ -99,7 +109,7 @@ extension CloudKitService { guard let created = results.first else { throw CloudKitError.invalidResponse } - return created + return try created.get() } /// Delete a single subscription by its identifier. @@ -112,6 +122,11 @@ extension CloudKitService { id: String, database: Database ) async throws(CloudKitError) { - _ = try await modifySubscriptions([.delete(subscriptionID: id)], database: database) + let results = try await modifySubscriptions([.delete(subscriptionID: id)], database: database) + // A successful delete yields a (skipped) ack, so an empty result set means + // success; surface any per-subscription failure CloudKit returned instead. + for result in results { + _ = try result.get() + } } } diff --git a/Sources/MistKit/Models/CloudKitServerErrorCode.swift b/Sources/MistKit/Models/CloudKitServerErrorCode.swift new file mode 100644 index 00000000..c17a0ca9 --- /dev/null +++ b/Sources/MistKit/Models/CloudKitServerErrorCode.swift @@ -0,0 +1,98 @@ +// +// CloudKitServerErrorCode.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. +// + +/// A CloudKit per-operation server error code. +/// +/// Shared by every per-item failure type (``RecordOperationFailure``, +/// ``SubscriptionOperationFailure``) so the whole API surfaces the same set of +/// codes. Mirrors CloudKit's documented `serverErrorCode` values; an +/// ``unknown(_:)`` case carries any code not yet known to this version of +/// MistKit so forward-compatibility never drops information. +public enum CloudKitServerErrorCode: Codable, Hashable, Sendable { + case accessDenied + case atomicError + case authenticationFailed + case authenticationRequired + case badRequest + case conflict + case exists + case internalError + case notFound + case quotaExceeded + case throttled + case tryAgainLater + case validatingReferenceError + case zoneNotFound + /// A server error code not recognized by this version of MistKit. + case unknown(String) + + /// The known (case, raw CloudKit string) pairs — the single source of truth + /// for converting in both directions. + private static let knownPairs: [(code: CloudKitServerErrorCode, raw: String)] = [ + (.accessDenied, "ACCESS_DENIED"), + (.atomicError, "ATOMIC_ERROR"), + (.authenticationFailed, "AUTHENTICATION_FAILED"), + (.authenticationRequired, "AUTHENTICATION_REQUIRED"), + (.badRequest, "BAD_REQUEST"), + (.conflict, "CONFLICT"), + (.exists, "EXISTS"), + (.internalError, "INTERNAL_ERROR"), + (.notFound, "NOT_FOUND"), + (.quotaExceeded, "QUOTA_EXCEEDED"), + (.throttled, "THROTTLED"), + (.tryAgainLater, "TRY_AGAIN_LATER"), + (.validatingReferenceError, "VALIDATING_REFERENCE_ERROR"), + (.zoneNotFound, "ZONE_NOT_FOUND"), + ] + + /// The raw CloudKit string for this code (e.g. `"NOT_FOUND"`). + public var rawValue: String { + if case .unknown(let raw) = self { + return raw + } + return Self.knownPairs.first { $0.code == self }?.raw ?? "" + } + + /// Maps a raw CloudKit string to a known case, or ``unknown(_:)``. + public init(rawValue: String) { + self = Self.knownPairs.first { $0.raw == rawValue }?.code ?? .unknown(rawValue) + } + + /// Decodes the code from its raw CloudKit string value. + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } + + /// Encodes the code as its raw CloudKit string value. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Sources/MistKit/Models/RecordOperationFailure.swift b/Sources/MistKit/Models/RecordOperationFailure.swift index 43895ed7..72c57653 100644 --- a/Sources/MistKit/Models/RecordOperationFailure.swift +++ b/Sources/MistKit/Models/RecordOperationFailure.swift @@ -48,71 +48,9 @@ internal import MistKitOpenAPI public struct RecordOperationFailure: Codable, Hashable, Sendable { /// The CloudKit server error code for a per-record failure. /// - /// Mirrors CloudKit's documented `serverErrorCode` values; an - /// ``unknown(_:)`` case carries any code not yet known to this version of - /// MistKit so forward-compatibility never drops information. - public enum ServerErrorCode: Codable, Hashable, Sendable { - case accessDenied - case atomicError - case authenticationFailed - case authenticationRequired - case badRequest - case conflict - case exists - case internalError - case notFound - case quotaExceeded - case throttled - case tryAgainLater - case validatingReferenceError - case zoneNotFound - /// A server error code not recognized by this version of MistKit. - case unknown(String) - - /// The known (case, raw CloudKit string) pairs — the single source of truth - /// for converting in both directions. - private static let knownPairs: [(code: ServerErrorCode, raw: String)] = [ - (.accessDenied, "ACCESS_DENIED"), - (.atomicError, "ATOMIC_ERROR"), - (.authenticationFailed, "AUTHENTICATION_FAILED"), - (.authenticationRequired, "AUTHENTICATION_REQUIRED"), - (.badRequest, "BAD_REQUEST"), - (.conflict, "CONFLICT"), - (.exists, "EXISTS"), - (.internalError, "INTERNAL_ERROR"), - (.notFound, "NOT_FOUND"), - (.quotaExceeded, "QUOTA_EXCEEDED"), - (.throttled, "THROTTLED"), - (.tryAgainLater, "TRY_AGAIN_LATER"), - (.validatingReferenceError, "VALIDATING_REFERENCE_ERROR"), - (.zoneNotFound, "ZONE_NOT_FOUND"), - ] - - /// The raw CloudKit string for this code (e.g. `"NOT_FOUND"`). - public var rawValue: String { - if case .unknown(let raw) = self { - return raw - } - return Self.knownPairs.first { $0.code == self }?.raw ?? "" - } - - /// Maps a raw CloudKit string to a known case, or ``unknown(_:)``. - public init(rawValue: String) { - self = Self.knownPairs.first { $0.raw == rawValue }?.code ?? .unknown(rawValue) - } - - /// Decodes the code from its raw CloudKit string value. - public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - self.init(rawValue: try container.decode(String.self)) - } - - /// Encodes the code as its raw CloudKit string value. - public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(rawValue) - } - } + /// Now the shared ``CloudKitServerErrorCode``; kept as a nested alias so + /// existing `RecordOperationFailure.ServerErrorCode` references compile. + public typealias ServerErrorCode = CloudKitServerErrorCode /// The name of the record the operation failed on. public let recordName: String diff --git a/Sources/MistKit/Models/SubscriptionOperationFailure.swift b/Sources/MistKit/Models/SubscriptionOperationFailure.swift new file mode 100644 index 00000000..4a52a5a0 --- /dev/null +++ b/Sources/MistKit/Models/SubscriptionOperationFailure.swift @@ -0,0 +1,102 @@ +// +// SubscriptionOperationFailure.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 MistKitOpenAPI + +/// A per-subscription failure returned inline in a CloudKit +/// `modifySubscriptions` response. +/// +/// CloudKit reports per-operation failures as error entries within the +/// otherwise-successful (HTTP 200) `subscriptions` array, carrying the failed +/// subscription's identifier, a server error code, and optional retry/redirect +/// hints. This is the subscriptions analogue of ``RecordOperationFailure`` — +/// without it, a failed create (e.g. CloudKit's `INTERNAL_ERROR` / +/// "could not find subscription we just created") would be silently dropped. +/// +/// This is a data payload describing a failure, **not** a Swift `Error` type; +/// it is surfaced via ``SubscriptionResult/failure(_:)`` from +/// `modifySubscriptions`, and wrapped in +/// ``CloudKitError/subscriptionOperationFailed(_:)`` (which *is* an `Error`) +/// when the single-subscription convenience (`createSubscription`) hits one. +public struct SubscriptionOperationFailure: Codable, Hashable, Sendable { + /// The CloudKit server error code for a per-subscription failure. + public typealias ServerErrorCode = CloudKitServerErrorCode + + /// The identifier of the subscription the operation failed on. + public let subscriptionID: String + /// The CloudKit server error code for the failure. + public let serverErrorCode: ServerErrorCode + /// A human-readable reason for the failure, if provided. + public let reason: String? + /// Suggested seconds to wait before retrying. Absent if not retryable. + public let retryAfter: Int? + /// A unique identifier for this error. + public let uuid: String? + /// Redirect URL for sign-in; present when `serverErrorCode` is + /// ``CloudKitServerErrorCode/authenticationRequired``. + public let redirectURL: String? + + /// Creates a per-subscription failure value. + public init( + subscriptionID: String, + serverErrorCode: ServerErrorCode, + reason: String? = nil, + retryAfter: Int? = nil, + uuid: String? = nil, + redirectURL: String? = nil + ) { + self.subscriptionID = subscriptionID + self.serverErrorCode = serverErrorCode + self.reason = reason + self.retryAfter = retryAfter + self.uuid = uuid + self.redirectURL = redirectURL + } + + internal init(from schema: Components.Schemas.SubscriptionOperationFailure) { + let serverErrorCode = ServerErrorCode(rawValue: schema.serverErrorCode.rawValue) + // The generated `serverErrorCodePayload` is a closed enum mirroring the + // schema, so a `.unknown` here means our `knownPairs` table drifted from + // the regenerated schema — assert loudly (test-overridable) while still + // preserving the raw code for forward-compatibility in release. + if case .unknown(let raw) = serverErrorCode { + ConversionFailureReporter.assertionHandler( + "Unmapped CloudKit serverErrorCode \"\(raw)\" — update CloudKitServerErrorCode.knownPairs", + #fileID, + #line + ) + } + self.subscriptionID = schema.subscriptionID + self.serverErrorCode = serverErrorCode + self.reason = schema.reason + self.retryAfter = schema.retryAfter + self.uuid = schema.uuid + self.redirectURL = schema.redirectURL + } +} diff --git a/Sources/MistKit/Models/SubscriptionResult.swift b/Sources/MistKit/Models/SubscriptionResult.swift new file mode 100644 index 00000000..54385171 --- /dev/null +++ b/Sources/MistKit/Models/SubscriptionResult.swift @@ -0,0 +1,68 @@ +// +// SubscriptionResult.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. +// + +/// The outcome of a single operation in a `modifySubscriptions` batch. +/// +/// CloudKit returns per-operation results inline in the response +/// `subscriptions` array: a successful create/update yields a subscription, +/// while a failed one yields an error describing what went wrong. +/// `SubscriptionResult` models that union — the subscriptions analogue of +/// ``RecordResult`` — so no per-subscription failure is silently dropped. +/// +/// ```swift +/// let results = try await service.modifySubscriptions(operations, database: .private) +/// for result in results { +/// switch result { +/// case .success(let subscription): print("saved \(subscription.subscriptionID)") +/// case .failure(let error): print("failed: \(error.serverErrorCode.rawValue)") +/// } +/// } +/// ``` +/// +/// - Note: A *deletion* acknowledgement (CloudKit echoes a bare +/// `{ subscriptionID }` with no type) is neither a success subscription nor a +/// failure, so it is omitted from the results rather than represented here. +public enum SubscriptionResult: Sendable { + /// The operation succeeded and CloudKit returned the resulting subscription. + case success(SubscriptionInfo) + /// The operation failed; the associated ``SubscriptionOperationFailure`` + /// describes the failure. + case failure(SubscriptionOperationFailure) + + /// Returns the subscription for a successful result, or throws + /// ``CloudKitError/subscriptionOperationFailed(_:)`` for a failure. + public func get() throws(CloudKitError) -> SubscriptionInfo { + switch self { + case .success(let subscription): + return subscription + case .failure(let error): + throw CloudKitError.subscriptionOperationFailed(error) + } + } +} diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index b3d2f7f9..a675875e 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -1958,15 +1958,136 @@ public enum Components { case subscriptions } } + /// Per-subscription error returned inline in the `subscriptions` array of a + /// 200 modify response. Identifies the subscription that failed and why. + /// Mirrors `RecordOperationFailure` for records. Distinct from + /// `ErrorResponse`, which is the body of a top-level 4xx/5xx HTTP failure. + /// + /// + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure`. + public struct SubscriptionOperationFailure: Codable, Hashable, Sendable { + /// The identifier of the subscription the operation failed on. + /// + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/subscriptionID`. + public var subscriptionID: Swift.String + /// The code for the error that occurred. + /// + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/serverErrorCode`. + @frozen public enum serverErrorCodePayload: String, Codable, Hashable, Sendable, CaseIterable { + case ACCESS_DENIED = "ACCESS_DENIED" + case ATOMIC_ERROR = "ATOMIC_ERROR" + case AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" + case AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED" + case BAD_REQUEST = "BAD_REQUEST" + case CONFLICT = "CONFLICT" + case EXISTS = "EXISTS" + case INTERNAL_ERROR = "INTERNAL_ERROR" + case NOT_FOUND = "NOT_FOUND" + case QUOTA_EXCEEDED = "QUOTA_EXCEEDED" + case THROTTLED = "THROTTLED" + case TRY_AGAIN_LATER = "TRY_AGAIN_LATER" + case VALIDATING_REFERENCE_ERROR = "VALIDATING_REFERENCE_ERROR" + case ZONE_NOT_FOUND = "ZONE_NOT_FOUND" + } + /// The code for the error that occurred. + /// + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/serverErrorCode`. + public var serverErrorCode: Components.Schemas.SubscriptionOperationFailure.serverErrorCodePayload + /// A string indicating the reason for the error. + /// + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/reason`. + public var reason: Swift.String? + /// Suggested seconds to wait before retrying. Absent if not retryable. + /// + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/retryAfter`. + public var retryAfter: Swift.Int? + /// A unique identifier for this error. + /// + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/uuid`. + public var uuid: Swift.String? + /// Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. + /// + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/redirectURL`. + public var redirectURL: Swift.String? + /// Creates a new `SubscriptionOperationFailure`. + /// + /// - Parameters: + /// - subscriptionID: The identifier of the subscription the operation failed on. + /// - serverErrorCode: The code for the error that occurred. + /// - reason: A string indicating the reason for the error. + /// - retryAfter: Suggested seconds to wait before retrying. Absent if not retryable. + /// - uuid: A unique identifier for this error. + /// - redirectURL: Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. + public init( + subscriptionID: Swift.String, + serverErrorCode: Components.Schemas.SubscriptionOperationFailure.serverErrorCodePayload, + reason: Swift.String? = nil, + retryAfter: Swift.Int? = nil, + uuid: Swift.String? = nil, + redirectURL: Swift.String? = nil + ) { + self.subscriptionID = subscriptionID + self.serverErrorCode = serverErrorCode + self.reason = reason + self.retryAfter = retryAfter + self.uuid = uuid + self.redirectURL = redirectURL + } + public enum CodingKeys: String, CodingKey { + case subscriptionID + case serverErrorCode + case reason + case retryAfter + case uuid + case redirectURL + } + } /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse`. public struct SubscriptionsModifyResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse/subscriptionsPayload`. + @frozen public enum subscriptionsPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse/subscriptionsPayload/case1`. + case SubscriptionOperationFailure(Components.Schemas.SubscriptionOperationFailure) + /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse/subscriptionsPayload/case2`. + case Subscription(Components.Schemas.Subscription) + public init(from decoder: any Decoder) throws { + var errors: [any Error] = [] + do { + self = .SubscriptionOperationFailure(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .Subscription(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .SubscriptionOperationFailure(value): + try value.encode(to: encoder) + case let .Subscription(value): + try value.encode(to: encoder) + } + } + } /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse/subscriptions`. - public var subscriptions: [Components.Schemas.Subscription]? + public typealias subscriptionsPayload = [Components.Schemas.SubscriptionsModifyResponse.subscriptionsPayloadPayload] + /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse/subscriptions`. + public var subscriptions: Components.Schemas.SubscriptionsModifyResponse.subscriptionsPayload? /// Creates a new `SubscriptionsModifyResponse`. /// /// - Parameters: /// - subscriptions: - public init(subscriptions: [Components.Schemas.Subscription]? = nil) { + public init(subscriptions: Components.Schemas.SubscriptionsModifyResponse.subscriptionsPayload? = nil) { self.subscriptions = subscriptions } public enum CodingKeys: String, CodingKey { diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift new file mode 100644 index 00000000..ad25791e --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift @@ -0,0 +1,152 @@ +// +// CloudKitServiceTests.Subscriptions+FailureCases.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.Subscriptions { + @Suite("Failure Cases") + internal struct FailureCases { + private static let database: Database = .public(.prefers(.serverToServer)) + private static let subID = "1CB64DC1-2423-486C-8C9F-4E064530FBEF" + + /// CloudKit returns a per-subscription failure inline in a 200 body. The + /// real-world case (#379): a create that comes back as `INTERNAL_ERROR` / + /// "could not find subscription we just created". Previously MistKit + /// dropped the typeless entry and returned an empty array; now the failure + /// is surfaced as a ``SubscriptionResult/failure(_:)``. + private static let internalErrorJSON = """ + { + "subscriptions": [ + { + "subscriptionID": "1CB64DC1-2423-486C-8C9F-4E064530FBEF", + "reason": "could not find subscription we just created", + "serverErrorCode": "INTERNAL_ERROR" + } + ] + } + """ + + private static func noteCreate(_ id: String) -> SubscriptionOperation { + .create(.query(subscriptionID: id, recordType: "Note", firesOn: [.create])) + } + + @Test("modifySubscriptions() surfaces a per-subscription failure") + internal func modifySurfacesFailure() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Subscriptions.makeService( + returningJSON: Self.internalErrorJSON + ) + + let results = try await service.modifySubscriptions( + [Self.noteCreate(Self.subID)], + database: Self.database + ) + + #expect(results.count == 1) + guard case .failure(let failure) = try #require(results.first) else { + Issue.record("expected a .failure result, got \(String(describing: results.first))") + return + } + #expect(failure.subscriptionID == Self.subID) + #expect(failure.serverErrorCode == .internalError) + #expect(failure.reason == "could not find subscription we just created") + } + + @Test("createSubscription() throws subscriptionOperationFailed on a failure entry") + internal func createThrowsOnFailure() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Subscriptions.makeService( + returningJSON: Self.internalErrorJSON + ) + + do { + _ = try await service.createSubscription( + .query(subscriptionID: Self.subID, recordType: "Note", firesOn: [.create]), + database: Self.database + ) + Issue.record("expected createSubscription to throw") + } catch let error as CloudKitError { + guard case .subscriptionOperationFailed(let failure) = error else { + Issue.record("expected .subscriptionOperationFailed, got \(error)") + return + } + #expect(failure.serverErrorCode == .internalError) + #expect(failure.subscriptionID == Self.subID) + } + } + + @Test("modifySubscriptions() returns mixed success and failure results") + internal func modifyReturnsMixed() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let json = """ + { + "subscriptions": [ + { "subscriptionID": "bad", "serverErrorCode": "INTERNAL_ERROR" }, + { + "subscriptionID": "good", + "subscriptionType": "query", + "query": { "recordType": "Article" }, + "firesOn": ["create"] + } + ] + } + """ + let service = try CloudKitServiceTests.Subscriptions.makeService(returningJSON: json) + + let results = try await service.modifySubscriptions( + [Self.noteCreate("bad"), Self.noteCreate("good")], + database: Self.database + ) + + #expect(results.count == 2) + guard case .failure(let failure) = results[0] else { + Issue.record("expected results[0] to be .failure") + return + } + #expect(failure.subscriptionID == "bad") + guard case .success(let subscription) = results[1] else { + Issue.record("expected results[1] to be .success") + return + } + #expect(subscription.subscriptionID == "good") + } + } +} diff --git a/openapi.yaml b/openapi.yaml index f7ca129a..ed6e3139 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1357,13 +1357,60 @@ components: items: $ref: '#/components/schemas/Subscription' + SubscriptionOperationFailure: + type: object + description: | + Per-subscription error returned inline in the `subscriptions` array of a + 200 modify response. Identifies the subscription that failed and why. + Mirrors `RecordOperationFailure` for records. Distinct from + `ErrorResponse`, which is the body of a top-level 4xx/5xx HTTP failure. + properties: + subscriptionID: + type: string + description: The identifier of the subscription the operation failed on. + serverErrorCode: + type: string + enum: + - ACCESS_DENIED + - ATOMIC_ERROR + - AUTHENTICATION_FAILED + - AUTHENTICATION_REQUIRED + - BAD_REQUEST + - CONFLICT + - EXISTS + - INTERNAL_ERROR + - NOT_FOUND + - QUOTA_EXCEEDED + - THROTTLED + - TRY_AGAIN_LATER + - VALIDATING_REFERENCE_ERROR + - ZONE_NOT_FOUND + description: The code for the error that occurred. + reason: + type: string + description: A string indicating the reason for the error. + retryAfter: + type: integer + description: Suggested seconds to wait before retrying. Absent if not retryable. + uuid: + type: string + description: A unique identifier for this error. + redirectURL: + type: string + description: Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. + required: + - subscriptionID + - serverErrorCode + SubscriptionsModifyResponse: type: object properties: subscriptions: type: array items: - $ref: '#/components/schemas/Subscription' + oneOf: + - $ref: '#/components/schemas/SubscriptionOperationFailure' + - $ref: '#/components/schemas/Subscription' RecordTimestamp: type: object From 9484bbd9a4cce9708de8e5a22736047f4ff5740b Mon Sep 17 00:00:00 2001 From: leogdion Date: Mon, 25 May 2026 18:05:39 -0400 Subject: [PATCH 08/14] Redesign SubscriptionInfo + unify Query; flag likely-duplicate (#379, #387) [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure SubscriptionInfo around a Kind enum (.query/.zone/.database) mirroring native CKSubscription subclasses; add NotificationInfo, firesOnce, and zoneWide per the CloudKit JS reference. firesOn and firesOnce are query-only — they live inside Kind.query, matching native CKQuerySubscriptionOptions. zone subscriptions no longer carry firesOn. SubscriptionFireEvents OptionSet replaces [SubscriptionFireEvent] array; init traps and the schema decoder throws ConversionError.subscriptionQueryMissingFiresOn when a query subscription has no fire events (a subscription that would never trigger is a programmer error). Unify CloudKit query representation in a new public Query type used by both queryRecords and SubscriptionInfo.Kind.query. SubscriptionQuery becomes a deprecated typealias. queryRecords(_ query: Query, ...) is the new canonical overload; the flat-param queryRecords is now @available(*, deprecated). Add isLikelyDuplicate on SubscriptionOperationFailure and CloudKitError.subscriptionLikelyDuplicate, thrown from createSubscription on the canonical INTERNAL_ERROR / "could not find subscription we just created" payload. Wording is hedged — the wire code is still INTERNAL_ERROR. Empirically verified via a new `mistdemo probe-duplicate-subscription` diagnostic command (uniqueness is by (recordType, firesOn), not subscriptionID — see #387). Consolidate RecordOperationFailure/SubscriptionOperationFailure into a generic OperationFailure with phantom-typed RecordTarget / SubscriptionTarget. The old per-target types become typealiases; .recordName / .subscriptionID become .identifier across callers. RecordResult / SubscriptionResult fold into OperationResult. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CloudKit/BushelCloudKitService.swift | 4 +- .../MistDemoKit/Commands/ModifyCommand.swift | 8 +- .../Commands/ModifySubscriptionsCommand.swift | 10 +- .../ProbeDuplicateSubscriptionCommand.swift | 273 +++++++++++++ .../Commands/ProbeExperiment.swift | 114 ++++++ .../ProbeDuplicateSubscriptionConfig.swift | 90 +++++ .../Sources/MistDemoKit/MistDemoRunner.swift | 1 + .../Server/WebRequests+Subscriptions.swift | 16 +- .../CloudKitService/CloudKitError.swift | 37 +- .../CloudKitService+Classification.swift | 2 +- .../CloudKitService+ModifySubscriptions.swift | 30 +- .../CloudKitService+Operations.swift | 51 ++- .../CloudKitService+QueryPagination.swift | 4 +- Sources/MistKit/Models/ConversionError.swift | 7 + Sources/MistKit/Models/OperationFailure.swift | 115 ++++++ ...ent.swift => OperationFailureTarget.swift} | 49 +-- Sources/MistKit/Models/OperationResult.swift | 70 ++++ Sources/MistKit/Models/Queries/Query.swift | 82 ++++ .../Models/RecordOperationFailure.swift | 77 +--- Sources/MistKit/Models/RecordResult.swift | 58 +-- Sources/MistKit/Models/RecordTarget.swift | 71 ++++ .../Models/SubscriptionOperationFailure.swift | 76 +--- .../MistKit/Models/SubscriptionResult.swift | 40 +- .../MistKit/Models/SubscriptionTarget.swift | 96 +++++ .../Subscriptions/NotificationInfo.swift | 133 +++++++ .../SubscriptionFireEvents.swift | 126 ++++++ .../SubscriptionInfo+Schema.swift | 131 ++++++ .../Subscriptions/SubscriptionInfo.swift | 322 +++++++++++---- .../Subscriptions/SubscriptionQuery.swift | 63 +-- Sources/MistKitOpenAPI/Types.swift | 373 ++++++++++++------ ...viceTests.Subscriptions+FailureCases.swift | 28 +- ...s.Subscriptions+LikelyDuplicateCases.swift | 151 +++++++ ...viceTests.Subscriptions+SuccessCases.swift | 4 +- .../Models/BatchSyncResultTests.swift | 2 +- .../Models/ConversionFailureTests.swift | 13 +- .../SubscriptionConversionTests.swift | 33 +- openapi.yaml | 208 ++++++---- 37 files changed, 2312 insertions(+), 656 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeExperiment.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift create mode 100644 Sources/MistKit/Models/OperationFailure.swift rename Sources/MistKit/Models/{Subscriptions/SubscriptionFireEvent.swift => OperationFailureTarget.swift} (54%) create mode 100644 Sources/MistKit/Models/OperationResult.swift create mode 100644 Sources/MistKit/Models/Queries/Query.swift create mode 100644 Sources/MistKit/Models/RecordTarget.swift create mode 100644 Sources/MistKit/Models/SubscriptionTarget.swift create mode 100644 Sources/MistKit/Models/Subscriptions/NotificationInfo.swift create mode 100644 Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift create mode 100644 Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift create mode 100644 Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift index c8ca3225..53f0cfce 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift @@ -253,9 +253,9 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol case .failure(let error): totalFailed += 1 batchFailed += 1 - failedRecordNames.append(error.recordName) + failedRecordNames.append(error.identifier) Self.logger.debug( - "Error: recordName=\(error.recordName), code=\(error.serverErrorCode.rawValue)" + "Error: recordName=\(error.identifier), code=\(error.serverErrorCode.rawValue)" ) case .success(let record): batchSucceeded += 1 diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift index ab7a91d6..dc8dbdd1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift @@ -105,10 +105,10 @@ public struct ModifyCommand: MistDemoCommand, OutputFormatting { if !failures.isEmpty { for failure in failures { - let line = - "Warning: operation on '\(failure.recordName)' failed" - + " (\(failure.serverErrorCode.rawValue))" - + (failure.reason.map { ": \($0)" } ?? "") + "\n" + 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)) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift index 895250aa..dc5274a2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift @@ -82,7 +82,15 @@ public struct ModifySubscriptionsCommand: MistDemoCommand, OutputFormatting { guard let recordType = config.recordType, !recordType.isEmpty else { throw SubscriptionCommandError.missingRecordType } - let firesOn = config.firesOn.compactMap(SubscriptionFireEvent.init(rawValue:)) + var firesOn: SubscriptionFireEvents = [] + for raw in config.firesOn { + switch raw { + case "create": firesOn.insert(.create) + case "update": firesOn.insert(.update) + case "delete": firesOn.insert(.delete) + default: break + } + } let created = try await service.createSubscription( .query( subscriptionID: subscriptionID, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand.swift new file mode 100644 index 00000000..6aeb2b4d --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand.swift @@ -0,0 +1,273 @@ +// +// 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 + +/// 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 + /// 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 + } + + /// 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: [(Int, String, String)] = [] + for experiment in experiments { + let outcome = await runExperiment( + experiment, + service: service, + database: database + ) + summary.append((experiment.index, experiment.label, outcome)) + } + + print("") + print("📋 Summary") + for (index, label, outcome) in summary { + print(" #\(index) \(label)") + print(" → \(outcome)") + } + } + + private 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] + ), + ] + } + + private 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) + ) + + // Seed + 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)" + } + + // Cleanup — best-effort, 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 + } + + private 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: ","))]" + } + + private 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: " ") + } + } + + private 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)" + } + } +} + +// `ProbeExperiment` and `ProbeSubscriptionTemplate` live in +// `ProbeExperiment.swift`. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeExperiment.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeExperiment.swift new file mode 100644 index 00000000..1efb8b4e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeExperiment.swift @@ -0,0 +1,114 @@ +// +// 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 func seedSubscription() -> SubscriptionInfo { + seed.materialize() + } + + internal func probeSubscription() -> SubscriptionInfo { + probe.materialize() + } + + 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 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/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/MistDemoRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift index 7d6828c2..554d1c7c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift @@ -72,6 +72,7 @@ public enum MistDemoRunner { 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) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Subscriptions.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Subscriptions.swift index 50214cdb..a088a096 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Subscriptions.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Subscriptions.swift @@ -97,13 +97,23 @@ extension WebRequests { /// Collapse the decoded create/delete inputs into MistKit operations. internal func operations() -> [SubscriptionOperation] { var operations: [SubscriptionOperation] = create.map { input in - let firesOn = (input.firesOn ?? []).compactMap(SubscriptionFireEvent.init(rawValue:)) + var firesOn: SubscriptionFireEvents = [] + for raw in input.firesOn ?? [] { + switch raw { + case "create": firesOn.insert(.create) + case "update": firesOn.insert(.update) + case "delete": firesOn.insert(.delete) + default: break + } + } if input.subscriptionType == "zone" { + // Zone subscriptions no longer carry firesOn — only query + // subscriptions do. Ignore the inbound `firesOn` for zone + // create requests. return .create( .zone( subscriptionID: input.subscriptionID ?? "", - zoneID: ZoneID(zoneName: input.zoneID?.zoneName ?? ""), - firesOn: firesOn + zoneID: ZoneID(zoneName: input.zoneID?.zoneName ?? "") ) ) } diff --git a/Sources/MistKit/CloudKitService/CloudKitError.swift b/Sources/MistKit/CloudKitService/CloudKitError.swift index 40ecc80e..9f06071d 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError.swift @@ -67,6 +67,14 @@ public enum CloudKitError: LocalizedError, Sendable { /// a `SubscriptionOperationFailure`, surfaced by the single-subscription /// convenience (`createSubscription`). case subscriptionOperationFailed(SubscriptionOperationFailure) + /// `createSubscription` failed with `INTERNAL_ERROR` and the exact reason + /// string CloudKit returns when a *semantically-matching* subscription + /// (same query + `firesOn`, regardless of `subscriptionID`) already + /// exists. **This is MistKit's inference, not a guaranteed cause** — the + /// underlying wire error is `INTERNAL_ERROR` with no formal "already + /// exists" code, and the original ``SubscriptionOperationFailure`` is + /// preserved so callers can inspect the raw signal. + case subscriptionLikelyDuplicate(SubscriptionOperationFailure) case underlyingError(any Error) case decodingError(DecodingError) case networkError(URLError) @@ -95,7 +103,8 @@ public enum CloudKitError: LocalizedError, Sendable { case .badRequest, .atomicFailure: return 400 case .invalidResponse, .conversionFailed, .recordOperationFailed, - .subscriptionOperationFailed, .underlyingError, .decodingError, .networkError, + .subscriptionOperationFailed, .subscriptionLikelyDuplicate, + .underlyingError, .decodingError, .networkError, .unsupportedOperationType, .paginationLimitExceeded, .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey: return nil @@ -124,21 +133,37 @@ public enum CloudKitError: LocalizedError, Sendable { return "Failed to convert CloudKit response into a MistKit type: " + (conversionError.errorDescription ?? "\(conversionError)") case .recordOperationFailed(let recordError): - var message = - "CloudKit record operation failed for '\(recordError.recordName)' " - + "(\(recordError.serverErrorCode.rawValue))" + let identifier = recordError.identifier + let code = recordError.serverErrorCode.rawValue + var message = "CloudKit record operation failed for '\(identifier)' (\(code))" if let reason = recordError.reason { message += "\nReason: \(reason)" } return message case .subscriptionOperationFailed(let subscriptionError): + let identifier = subscriptionError.identifier + let code = subscriptionError.serverErrorCode.rawValue var message = - "CloudKit subscription operation failed for '\(subscriptionError.subscriptionID)' " - + "(\(subscriptionError.serverErrorCode.rawValue))" + "CloudKit subscription operation failed for '\(identifier)' (\(code))" if let reason = subscriptionError.reason { message += "\nReason: \(reason)" } return message + case .subscriptionLikelyDuplicate(let subscriptionError): + let identifier = subscriptionError.identifier + let reasonFragment: String + if let reason = subscriptionError.reason { + reasonFragment = "\"\(reason)\")." + } else { + reasonFragment = "no reason)." + } + var message = "CloudKit subscription create returned INTERNAL_ERROR for '\(identifier)'." + message += "\nLikely cause: another subscription matching this query and firesOn" + message += " already exists (CloudKit reports semantically-duplicate" + message += " subscriptions as INTERNAL_ERROR / " + message += reasonFragment + message += "\nUse listSubscriptions to find and reuse or delete the existing one." + return message case .underlyingError(let error): return "CloudKit operation failed with underlying error: \(String(reflecting: error))" case .decodingError(let error): diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift index f42ffe18..5e1c02df 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift @@ -68,7 +68,7 @@ extension CloudKitService { database: Database ) async throws(CloudKitError) -> Set { let result: QueryResult = try await queryRecords( - recordType: recordType, + Query(recordType: recordType), limit: limit ?? Self.maxRecordsPerRequest, database: database ) diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift index 4525ba9a..29052444 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift @@ -64,22 +64,7 @@ extension CloudKitService { let subscriptionsData: Components.Schemas.SubscriptionsModifyResponse = try await responseProcessor.processModifySubscriptionsResponse(response) return try (subscriptionsData.subscriptions ?? []).compactMap { - item -> SubscriptionResult? in - switch item { - case .SubscriptionOperationFailure(let failure): - // A per-subscription error CloudKit returned inline in the 200 body - // (e.g. INTERNAL_ERROR on create). Surface it rather than dropping it. - return .failure(SubscriptionOperationFailure(from: failure)) - case .Subscription(let subscription): - // CloudKit echoes a deleted subscription as a bare `{ subscriptionID }` - // with no `subscriptionType`/`query`/`firesOn`. That's a deletion - // acknowledgement, not a result — skip it rather than treating the - // missing type as a conversion failure. - guard subscription.subscriptionType != nil else { - return nil - } - return .success(try SubscriptionInfo(from: subscription)) - } + try SubscriptionResult(from: $0) } } catch { throw mapToCloudKitError(error, context: "modifySubscriptions") @@ -96,10 +81,12 @@ extension CloudKitService { /// - subscription: The subscription to create. /// - database: The CloudKit database scope to modify. /// - Returns: The created subscription as returned by CloudKit. - /// - Throws: ``CloudKitError/subscriptionOperationFailed(_:)`` if CloudKit - /// rejected the create (e.g. `INTERNAL_ERROR`), ``CloudKitError`` if the - /// request fails, or ``CloudKitError/invalidResponse`` if the response is - /// empty. + /// - Throws: ``CloudKitError/subscriptionLikelyDuplicate(_:)`` when CloudKit + /// returns `INTERNAL_ERROR` with its duplicate-subscription marker + /// reason (a *hint*, not a confirmed cause — see the case docs), + /// ``CloudKitError/subscriptionOperationFailed(_:)`` for any other + /// per-subscription failure, ``CloudKitError`` if the request fails, + /// or ``CloudKitError/invalidResponse`` if the response is empty. @discardableResult public func createSubscription( _ subscription: SubscriptionInfo, @@ -109,6 +96,9 @@ extension CloudKitService { guard let created = results.first else { throw CloudKitError.invalidResponse } + if case .failure(let failure) = created, failure.isLikelyDuplicate { + throw CloudKitError.subscriptionLikelyDuplicate(failure) + } return try created.get() } diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift index 4660d6ce..0e52d7ca 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift @@ -144,6 +144,11 @@ extension CloudKitService { /// marker = result.continuationMarker /// } while marker != nil /// ``` + @available( + *, deprecated, + message: + "Use queryRecords(_:limit:desiredKeys:continuationMarker:database:) — pass a Query value." + ) public func queryRecords( recordType: String, filters: [QueryFilter]? = nil, @@ -153,14 +158,40 @@ extension CloudKitService { continuationMarker: String? = nil, database: Database ) async throws(CloudKitError) -> QueryResult { - let effectiveLimit = limit ?? defaultQueryLimit + try await queryRecords( + Query(recordType: recordType, filters: filters ?? [], sortBy: sortBy ?? []), + limit: limit, + desiredKeys: desiredKeys, + continuationMarker: continuationMarker, + database: database + ) + } - let componentsFilters = filters?.map { - Components.Schemas.Filter(from: $0) - } - let componentsSorts = sortBy?.map { - Components.Schemas.Sort(from: $0) - } + /// Query records from the default zone with pagination support. + /// + /// The unified ``Query`` value carries the `recordType` plus any + /// ``QueryFilter`` predicates and ``QuerySort`` descriptors. The same + /// value can be embedded in a subscription via + /// ``SubscriptionInfo/Kind/query(_:)``. + /// + /// - Parameters: + /// - query: The query to execute. + /// - limit: Maximum records to return (defaults to `defaultQueryLimit`). + /// - desiredKeys: Optional list of field names to fetch. + /// - continuationMarker: Marker from a previous ``QueryResult`` to + /// fetch the next page. + /// - database: The CloudKit database scope to query. + /// - Returns: A ``QueryResult`` with matching records and an optional + /// continuation marker. + /// - Throws: ``CloudKitError`` if validation fails or the request fails. + public func queryRecords( + _ query: Query, + limit: Int? = nil, + desiredKeys: [String]? = nil, + continuationMarker: String? = nil, + database: Database + ) async throws(CloudKitError) -> QueryResult { + let effectiveLimit = limit ?? defaultQueryLimit do { let client = try self.client(for: database) @@ -175,11 +206,7 @@ extension CloudKitService { .init( zoneID: .init(zoneName: "_defaultZone"), resultsLimit: effectiveLimit, - query: .init( - recordType: recordType, - filterBy: componentsFilters, - sortBy: componentsSorts - ), + query: query.schema, desiredKeys: desiredKeys, continuationMarker: continuationMarker ) diff --git a/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift b/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift index d87a7253..551bdcdf 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift @@ -83,9 +83,7 @@ extension CloudKitService { } let result: QueryResult = try await queryRecords( - recordType: recordType, - filters: filters, - sortBy: sortBy, + Query(recordType: recordType, filters: filters ?? [], sortBy: sortBy ?? []), limit: pageSize, desiredKeys: desiredKeys, continuationMarker: currentMarker, diff --git a/Sources/MistKit/Models/ConversionError.swift b/Sources/MistKit/Models/ConversionError.swift index 25b6e6d2..05386546 100644 --- a/Sources/MistKit/Models/ConversionError.swift +++ b/Sources/MistKit/Models/ConversionError.swift @@ -68,6 +68,10 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { case subscriptionMissingID /// A subscription response was missing its `subscriptionType`. case subscriptionMissingType + /// A `query` subscription response was missing or had an empty + /// `firesOn` array — a query subscription with no fire events would + /// never trigger and is invalid by construction. + case subscriptionQueryMissingFiresOn /// A token response was missing or malformed a required field /// (`apnsEnvironment`/`apnsToken`/`webcourierURL`). case tokenMissingField(fieldName: String) @@ -105,6 +109,9 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { return "Subscription entry missing subscriptionID" case .subscriptionMissingType: return "Subscription entry missing subscriptionType" + case .subscriptionQueryMissingFiresOn: + return "Query subscription missing or empty firesOn — a query " + + "subscription must declare at least one of [create, update, delete]" case .tokenMissingField(let fieldName): return "TokenResponse missing required field '\(fieldName)'" } diff --git a/Sources/MistKit/Models/OperationFailure.swift b/Sources/MistKit/Models/OperationFailure.swift new file mode 100644 index 00000000..e26acb14 --- /dev/null +++ b/Sources/MistKit/Models/OperationFailure.swift @@ -0,0 +1,115 @@ +// +// OperationFailure.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 MistKitOpenAPI + +/// A per-item failure returned inline in a CloudKit batch response. +/// +/// CloudKit reports per-operation failures as error entries within the +/// otherwise-successful (HTTP 200) `records` or `subscriptions` array, carrying +/// the failed item's identifier, a server error code, and optional +/// retry/redirect hints. `OperationFailure` is the unified data payload +/// describing such a failure; it is **not** a Swift `Error` — it is surfaced +/// via ``OperationResult/failure(_:)`` and wrapped in ``CloudKitError`` (via +/// ``OperationFailureTarget/wrap(_:)``) when a single-item convenience method +/// hits one. +/// +/// The phantom ``OperationFailureTarget`` parameter distinguishes per-record +/// failures (``RecordOperationFailure``) from per-subscription failures +/// (``SubscriptionOperationFailure``) at the type level so the compiler +/// prevents mixing them in batch-result arrays. +public struct OperationFailure: + Codable, Hashable, Sendable +{ + /// The CloudKit server error code for a per-item failure. + public typealias ServerErrorCode = CloudKitServerErrorCode + + /// The wire identifier of the item the operation failed on + /// (a record name for ``RecordOperationFailure``, a subscription ID for + /// ``SubscriptionOperationFailure``). + public let identifier: String + /// The CloudKit server error code for the failure. + public let serverErrorCode: ServerErrorCode + /// A human-readable reason for the failure, if provided. + public let reason: String? + /// Suggested seconds to wait before retrying. Absent if not retryable. + public let retryAfter: Int? + /// A unique identifier for this error. + public let uuid: String? + /// Redirect URL for sign-in; present when `serverErrorCode` is + /// ``CloudKitServerErrorCode/authenticationRequired``. + public let redirectURL: String? + + /// Creates a per-item failure value. + public init( + identifier: String, + serverErrorCode: ServerErrorCode, + reason: String? = nil, + retryAfter: Int? = nil, + uuid: String? = nil, + redirectURL: String? = nil + ) { + self.identifier = identifier + self.serverErrorCode = serverErrorCode + self.reason = reason + self.retryAfter = retryAfter + self.uuid = uuid + self.redirectURL = redirectURL + } + + /// Shared initializer for per-target adapters: takes the wire identifier + /// plus the generated `OperationFailureCommon` payload (everything the two + /// failure schemas share via `allOf`). + internal init( + identifier: String, + common: Components.Schemas.OperationFailureCommon + ) { + let serverErrorCode = ServerErrorCode(rawValue: common.serverErrorCode.rawValue) + // The generated `OperationFailureServerErrorCode` is a closed enum mirroring + // the schema, so a `.unknown` here means our `knownPairs` table drifted + // from the regenerated schema — assert loudly (test-overridable) while + // still preserving the raw code for forward-compatibility in release. + if case .unknown(let raw) = serverErrorCode { + ConversionFailureReporter.assertionHandler( + "Unmapped CloudKit serverErrorCode \"\(raw)\"" + + " — update CloudKitServerErrorCode.knownPairs", + #fileID, + #line + ) + } + self.init( + identifier: identifier, + serverErrorCode: serverErrorCode, + reason: common.reason, + retryAfter: common.retryAfter, + uuid: common.uuid, + redirectURL: common.redirectURL + ) + } +} diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvent.swift b/Sources/MistKit/Models/OperationFailureTarget.swift similarity index 54% rename from Sources/MistKit/Models/Subscriptions/SubscriptionFireEvent.swift rename to Sources/MistKit/Models/OperationFailureTarget.swift index 710a558d..d2394bd9 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvent.swift +++ b/Sources/MistKit/Models/OperationFailureTarget.swift @@ -1,5 +1,5 @@ // -// SubscriptionFireEvent.swift +// OperationFailureTarget.swift // MistKit // // Created by Leo Dion. @@ -27,41 +27,16 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import MistKitOpenAPI - -/// A record-change event that causes a subscription to fire a push. +/// Identifies which kind of CloudKit batch a per-item failure came from. /// -/// A subscription's `firesOn` set selects which of these trigger a notification. -public enum SubscriptionFireEvent: String, Codable, Sendable, CaseIterable { - /// Fire when a matching record is created. - case create - /// Fire when a matching record is updated. - case update - /// Fire when a matching record is deleted. - case delete -} - -// MARK: - Internal Conversion -extension SubscriptionFireEvent { - internal var schemaValue: Components.Schemas.Subscription.firesOnPayloadPayload { - switch self { - case .create: - return .create - case .update: - return .update - case .delete: - return .delete - } - } - - internal init(from payload: Components.Schemas.Subscription.firesOnPayloadPayload) { - switch payload { - case .create: - self = .create - case .update: - self = .update - case .delete: - self = .delete - } - } +/// Each conforming target enum (``RecordTarget``, ``SubscriptionTarget``) names +/// the family of operations the failure belongs to and knows how the failure +/// should be lifted into the throwing API as a ``CloudKitError`` — letting +/// ``OperationResult/get()`` and the single-item convenience wrappers +/// (`createRecord`, `createSubscription`, …) throw the right case without the +/// generic having to switch on the target itself. +public protocol OperationFailureTarget: Sendable { + /// Lift a per-item failure into the corresponding ``CloudKitError`` case so + /// the throwing convenience APIs can rethrow without inspecting the target. + static func wrap(_ failure: OperationFailure) -> CloudKitError } diff --git a/Sources/MistKit/Models/OperationResult.swift b/Sources/MistKit/Models/OperationResult.swift new file mode 100644 index 00000000..57acbd33 --- /dev/null +++ b/Sources/MistKit/Models/OperationResult.swift @@ -0,0 +1,70 @@ +// +// OperationResult.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. +// + +/// The outcome of a single operation in a CloudKit batch. +/// +/// CloudKit returns per-operation results inline in modify/lookup response +/// arrays: a successful operation yields the new item, while a failed one +/// yields an error describing what went wrong. `OperationResult` models that +/// union so no per-item failure is silently dropped. +/// +/// Not built on `Swift.Result` because that requires `Failure: Error`, but +/// ``OperationFailure`` is intentionally a data payload — surfaced as data in +/// batch results and wrapped in ``CloudKitError`` only when a single-item +/// convenience method needs to throw. +/// +/// ```swift +/// let results = try await service.modifyRecords(operations, database: .private) +/// for result in results { +/// switch result { +/// case .success(let record): print("saved \(record.recordName)") +/// case .failure(let error): print("failed \(error.identifier): \(error.serverErrorCode.rawValue)") +/// } +/// } +/// ``` +public enum OperationResult: Sendable { + /// The operation succeeded and CloudKit returned the resulting item. + case success(Success) + /// The operation failed; the associated ``OperationFailure`` describes + /// the failure. + case failure(OperationFailure) + + /// Returns the successful value, or throws the failure wrapped in its + /// target's ``CloudKitError`` case (``CloudKitError/recordOperationFailed(_:)`` + /// for records, ``CloudKitError/subscriptionOperationFailed(_:)`` for + /// subscriptions). + public func get() throws(CloudKitError) -> Success { + switch self { + case .success(let value): + return value + case .failure(let failure): + throw Target.wrap(failure) + } + } +} diff --git a/Sources/MistKit/Models/Queries/Query.swift b/Sources/MistKit/Models/Queries/Query.swift new file mode 100644 index 00000000..01d7fbab --- /dev/null +++ b/Sources/MistKit/Models/Queries/Query.swift @@ -0,0 +1,82 @@ +// +// Query.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 MistKitOpenAPI + +/// A CloudKit query — a `recordType` plus optional ``QueryFilter`` +/// predicates and ``QuerySort`` descriptors. +/// +/// The same value can be passed to +/// ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:database:)`` +/// for a one-off query and embedded in +/// ``SubscriptionInfo/Kind/query(_:)`` to describe a query +/// subscription's predicate — they share this single representation. +public struct Query: Codable, Sendable { + // MARK: - Internal + + internal let schema: Components.Schemas.Query + + // MARK: - Public + + /// The record type this query targets, as serialized to CloudKit. + public var recordType: String? { + self.schema.recordType + } + + // MARK: - Lifecycle + + internal init(_ schema: Components.Schemas.Query) { + self.schema = schema + } + + /// Build a CloudKit query. + /// - Parameters: + /// - recordType: The record type this query targets. + /// - filters: Optional predicate filters (``QueryFilter``). + /// - sortBy: Optional sort descriptors (``QuerySort``). + public init( + recordType: String, + filters: [QueryFilter] = [], + sortBy: [QuerySort] = [] + ) { + self.schema = Components.Schemas.Query( + recordType: recordType, + filterBy: filters.isEmpty ? nil : filters.map(\.filter), + sortBy: sortBy.isEmpty ? nil : sortBy.map(\.sort) + ) + } + + public init(from decoder: any Decoder) throws { + self.schema = try Components.Schemas.Query(from: decoder) + } + + public func encode(to encoder: any Encoder) throws { + try self.schema.encode(to: encoder) + } +} diff --git a/Sources/MistKit/Models/RecordOperationFailure.swift b/Sources/MistKit/Models/RecordOperationFailure.swift index 72c57653..3f7b045e 100644 --- a/Sources/MistKit/Models/RecordOperationFailure.swift +++ b/Sources/MistKit/Models/RecordOperationFailure.swift @@ -27,80 +27,11 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import MistKitOpenAPI - /// A per-record failure returned inline in a CloudKit `modifyRecords` or /// `lookupRecords` response. /// -/// CloudKit reports per-operation failures as error entries within the -/// otherwise-successful (HTTP 200) `records` array, carrying the failed -/// record's name, a server error code, and optional retry/redirect hints. -/// -/// This is a data payload describing a failure, **not** a Swift `Error` type; -/// it is surfaced via ``RecordResult/failure(_:)`` from `modifyRecords` / +/// Surfaced via ``RecordResult/failure(_:)`` from `modifyRecords` / /// `lookupRecords`, and wrapped in ``CloudKitError/recordOperationFailed(_:)`` -/// (which *is* an `Error`) when a single-record convenience -/// (`createRecord`/`updateRecord`/`deleteRecord`) hits one. -/// -/// `RecordOperationFailure` is a MistKit-owned value, so callers can inspect a -/// failure with only `import MistKit` — no need to import the generated -/// `MistKitOpenAPI` module. -public struct RecordOperationFailure: Codable, Hashable, Sendable { - /// The CloudKit server error code for a per-record failure. - /// - /// Now the shared ``CloudKitServerErrorCode``; kept as a nested alias so - /// existing `RecordOperationFailure.ServerErrorCode` references compile. - public typealias ServerErrorCode = CloudKitServerErrorCode - - /// The name of the record the operation failed on. - public let recordName: String - /// The CloudKit server error code for the failure. - public let serverErrorCode: ServerErrorCode - /// A human-readable reason for the failure, if provided. - public let reason: String? - /// Suggested seconds to wait before retrying. Absent if not retryable. - public let retryAfter: Int? - /// A unique identifier for this error. - public let uuid: String? - /// Redirect URL for sign-in; present when `serverErrorCode` is - /// ``ServerErrorCode/authenticationRequired``. - public let redirectURL: String? - - /// Creates a per-record failure value. - public init( - recordName: String, - serverErrorCode: ServerErrorCode, - reason: String? = nil, - retryAfter: Int? = nil, - uuid: String? = nil, - redirectURL: String? = nil - ) { - self.recordName = recordName - self.serverErrorCode = serverErrorCode - self.reason = reason - self.retryAfter = retryAfter - self.uuid = uuid - self.redirectURL = redirectURL - } - - internal init(from schema: Components.Schemas.RecordOperationFailure) { - let serverErrorCode = ServerErrorCode(rawValue: schema.serverErrorCode.rawValue) - // The generated `serverErrorCodePayload` is a closed enum mirroring the - // schema, so a `.unknown` here means our `knownPairs` table drifted from - // the regenerated schema — assert loudly (test-overridable) while still - // preserving the raw code for forward-compatibility in release. - if case .unknown(let raw) = serverErrorCode { - ConversionFailureReporter.assertionHandler( - "Unmapped CloudKit serverErrorCode \"\(raw)\" — update ServerErrorCode.knownPairs", - #fileID, - #line - ) - } - self.recordName = schema.recordName - self.serverErrorCode = serverErrorCode - self.reason = schema.reason - self.retryAfter = schema.retryAfter - self.uuid = schema.uuid - self.redirectURL = schema.redirectURL - } -} +/// when a single-record convenience method (`createRecord`/`updateRecord`/ +/// `deleteRecord`) hits one. +public typealias RecordOperationFailure = OperationFailure diff --git a/Sources/MistKit/Models/RecordResult.swift b/Sources/MistKit/Models/RecordResult.swift index 7bcec112..06618a0a 100644 --- a/Sources/MistKit/Models/RecordResult.swift +++ b/Sources/MistKit/Models/RecordResult.swift @@ -27,62 +27,6 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import MistKitOpenAPI - /// The outcome of a single operation in a `modifyRecords` or `lookupRecords` /// batch. -/// -/// CloudKit returns per-operation results inline in the response `records` -/// array: a successful operation yields a record, while a failed one yields an -/// error describing what went wrong. `RecordResult` models that union so no -/// per-record failure is silently dropped. -/// -/// ```swift -/// let results = try await service.modifyRecords(operations, database: .private) -/// for result in results { -/// switch result { -/// case .success(let record): print("saved \(record.recordName)") -/// case .failure(let error): print("failed \(error.recordName): \(error.serverErrorCode.rawValue)") -/// } -/// } -/// ``` -public enum RecordResult: Sendable { - /// The operation succeeded and CloudKit returned the resulting record. - case success(RecordInfo) - /// The operation failed; the associated ``RecordOperationFailure`` describes - /// the failure. - case failure(RecordOperationFailure) - - internal init( - from item: Components.Schemas.ModifyResponse.recordsPayloadPayload - ) throws(ConversionError) { - switch item { - case .RecordOperationFailure(let error): - self = .failure(RecordOperationFailure(from: error)) - case .RecordResponse(let record): - self = .success(try RecordInfo(from: record)) - } - } - - internal init( - from item: Components.Schemas.LookupResponse.recordsPayloadPayload - ) throws(ConversionError) { - switch item { - case .RecordOperationFailure(let error): - self = .failure(RecordOperationFailure(from: error)) - case .RecordResponse(let record): - self = .success(try RecordInfo(from: record)) - } - } - - /// Returns the record for a successful result, or throws - /// ``CloudKitError/recordOperationFailed(_:)`` for a failure. - public func get() throws(CloudKitError) -> RecordInfo { - switch self { - case .success(let record): - return record - case .failure(let error): - throw CloudKitError.recordOperationFailed(error) - } - } -} +public typealias RecordResult = OperationResult diff --git a/Sources/MistKit/Models/RecordTarget.swift b/Sources/MistKit/Models/RecordTarget.swift new file mode 100644 index 00000000..8d469839 --- /dev/null +++ b/Sources/MistKit/Models/RecordTarget.swift @@ -0,0 +1,71 @@ +// +// RecordTarget.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 MistKitOpenAPI + +/// Phantom target tagging an ``OperationFailure`` (or ``OperationResult``) as +/// belonging to a per-record CloudKit batch (`modifyRecords` / `lookupRecords`). +public enum RecordTarget: OperationFailureTarget { + /// Lifts a per-record failure into ``CloudKitError/recordOperationFailed(_:)``. + public static func wrap( + _ failure: OperationFailure + ) -> CloudKitError { + .recordOperationFailed(failure) + } +} + +extension OperationFailure where Target == RecordTarget { + internal init(from schema: Components.Schemas.RecordOperationFailure) { + self.init(identifier: schema.value2.recordName, common: schema.value1) + } +} + +extension OperationResult where Success == RecordInfo, Target == RecordTarget { + internal init( + from item: Components.Schemas.ModifyResponse.recordsPayloadPayload + ) throws(ConversionError) { + switch item { + case .RecordOperationFailure(let failure): + self = .failure(OperationFailure(from: failure)) + case .RecordResponse(let record): + self = .success(try RecordInfo(from: record)) + } + } + + internal init( + from item: Components.Schemas.LookupResponse.recordsPayloadPayload + ) throws(ConversionError) { + switch item { + case .RecordOperationFailure(let failure): + self = .failure(OperationFailure(from: failure)) + case .RecordResponse(let record): + self = .success(try RecordInfo(from: record)) + } + } +} diff --git a/Sources/MistKit/Models/SubscriptionOperationFailure.swift b/Sources/MistKit/Models/SubscriptionOperationFailure.swift index 4a52a5a0..b9abb991 100644 --- a/Sources/MistKit/Models/SubscriptionOperationFailure.swift +++ b/Sources/MistKit/Models/SubscriptionOperationFailure.swift @@ -27,76 +27,12 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import MistKitOpenAPI - /// A per-subscription failure returned inline in a CloudKit /// `modifySubscriptions` response. /// -/// CloudKit reports per-operation failures as error entries within the -/// otherwise-successful (HTTP 200) `subscriptions` array, carrying the failed -/// subscription's identifier, a server error code, and optional retry/redirect -/// hints. This is the subscriptions analogue of ``RecordOperationFailure`` — -/// without it, a failed create (e.g. CloudKit's `INTERNAL_ERROR` / -/// "could not find subscription we just created") would be silently dropped. -/// -/// This is a data payload describing a failure, **not** a Swift `Error` type; -/// it is surfaced via ``SubscriptionResult/failure(_:)`` from -/// `modifySubscriptions`, and wrapped in -/// ``CloudKitError/subscriptionOperationFailed(_:)`` (which *is* an `Error`) -/// when the single-subscription convenience (`createSubscription`) hits one. -public struct SubscriptionOperationFailure: Codable, Hashable, Sendable { - /// The CloudKit server error code for a per-subscription failure. - public typealias ServerErrorCode = CloudKitServerErrorCode - - /// The identifier of the subscription the operation failed on. - public let subscriptionID: String - /// The CloudKit server error code for the failure. - public let serverErrorCode: ServerErrorCode - /// A human-readable reason for the failure, if provided. - public let reason: String? - /// Suggested seconds to wait before retrying. Absent if not retryable. - public let retryAfter: Int? - /// A unique identifier for this error. - public let uuid: String? - /// Redirect URL for sign-in; present when `serverErrorCode` is - /// ``CloudKitServerErrorCode/authenticationRequired``. - public let redirectURL: String? - - /// Creates a per-subscription failure value. - public init( - subscriptionID: String, - serverErrorCode: ServerErrorCode, - reason: String? = nil, - retryAfter: Int? = nil, - uuid: String? = nil, - redirectURL: String? = nil - ) { - self.subscriptionID = subscriptionID - self.serverErrorCode = serverErrorCode - self.reason = reason - self.retryAfter = retryAfter - self.uuid = uuid - self.redirectURL = redirectURL - } - - internal init(from schema: Components.Schemas.SubscriptionOperationFailure) { - let serverErrorCode = ServerErrorCode(rawValue: schema.serverErrorCode.rawValue) - // The generated `serverErrorCodePayload` is a closed enum mirroring the - // schema, so a `.unknown` here means our `knownPairs` table drifted from - // the regenerated schema — assert loudly (test-overridable) while still - // preserving the raw code for forward-compatibility in release. - if case .unknown(let raw) = serverErrorCode { - ConversionFailureReporter.assertionHandler( - "Unmapped CloudKit serverErrorCode \"\(raw)\" — update CloudKitServerErrorCode.knownPairs", - #fileID, - #line - ) - } - self.subscriptionID = schema.subscriptionID - self.serverErrorCode = serverErrorCode - self.reason = schema.reason - self.retryAfter = schema.retryAfter - self.uuid = schema.uuid - self.redirectURL = schema.redirectURL - } -} +/// Surfaced via ``SubscriptionResult/failure(_:)`` from `modifySubscriptions`, +/// and wrapped in ``CloudKitError/subscriptionOperationFailed(_:)`` (or +/// ``CloudKitError/subscriptionLikelyDuplicate(_:)`` for the +/// ``OperationFailure/isLikelyDuplicate`` case) when the single-subscription +/// convenience `createSubscription` hits one. +public typealias SubscriptionOperationFailure = OperationFailure diff --git a/Sources/MistKit/Models/SubscriptionResult.swift b/Sources/MistKit/Models/SubscriptionResult.swift index 54385171..dbff393b 100644 --- a/Sources/MistKit/Models/SubscriptionResult.swift +++ b/Sources/MistKit/Models/SubscriptionResult.swift @@ -29,40 +29,8 @@ /// The outcome of a single operation in a `modifySubscriptions` batch. /// -/// CloudKit returns per-operation results inline in the response -/// `subscriptions` array: a successful create/update yields a subscription, -/// while a failed one yields an error describing what went wrong. -/// `SubscriptionResult` models that union — the subscriptions analogue of -/// ``RecordResult`` — so no per-subscription failure is silently dropped. -/// -/// ```swift -/// let results = try await service.modifySubscriptions(operations, database: .private) -/// for result in results { -/// switch result { -/// case .success(let subscription): print("saved \(subscription.subscriptionID)") -/// case .failure(let error): print("failed: \(error.serverErrorCode.rawValue)") -/// } -/// } -/// ``` -/// /// - Note: A *deletion* acknowledgement (CloudKit echoes a bare -/// `{ subscriptionID }` with no type) is neither a success subscription nor a -/// failure, so it is omitted from the results rather than represented here. -public enum SubscriptionResult: Sendable { - /// The operation succeeded and CloudKit returned the resulting subscription. - case success(SubscriptionInfo) - /// The operation failed; the associated ``SubscriptionOperationFailure`` - /// describes the failure. - case failure(SubscriptionOperationFailure) - - /// Returns the subscription for a successful result, or throws - /// ``CloudKitError/subscriptionOperationFailed(_:)`` for a failure. - public func get() throws(CloudKitError) -> SubscriptionInfo { - switch self { - case .success(let subscription): - return subscription - case .failure(let error): - throw CloudKitError.subscriptionOperationFailed(error) - } - } -} +/// `{ subscriptionID }` with no type) is neither a success subscription nor +/// a failure, so it is omitted from `modifySubscriptions` results rather +/// than represented here. +public typealias SubscriptionResult = OperationResult diff --git a/Sources/MistKit/Models/SubscriptionTarget.swift b/Sources/MistKit/Models/SubscriptionTarget.swift new file mode 100644 index 00000000..828a4e7f --- /dev/null +++ b/Sources/MistKit/Models/SubscriptionTarget.swift @@ -0,0 +1,96 @@ +// +// SubscriptionTarget.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 MistKitOpenAPI + +/// Phantom target tagging an ``OperationFailure`` (or ``OperationResult``) as +/// belonging to a per-subscription CloudKit batch (`modifySubscriptions`). +public enum SubscriptionTarget: OperationFailureTarget { + /// Lifts a per-subscription failure into + /// ``CloudKitError/subscriptionOperationFailed(_:)``. + public static func wrap( + _ failure: OperationFailure + ) -> CloudKitError { + .subscriptionOperationFailed(failure) + } +} + +extension OperationFailure where Target == SubscriptionTarget { + /// The exact `reason` string CloudKit currently returns when a + /// `subscriptions/modify` create collides with an existing subscription + /// whose query + `firesOn` already match. The match is intentionally + /// **exact** — the reason is server-controlled and a loose substring + /// match risks false positives on unrelated future `INTERNAL_ERROR` + /// variants. Revisit only if `mistdemo probe-duplicate-subscription` + /// surfaces wording variants. + internal static var duplicateMarker: String { + "could not find subscription we just created" + } + + /// `true` when CloudKit returned `INTERNAL_ERROR` with the exact reason + /// string that, in practice, signals another subscription with matching + /// properties (query/`firesOn` — *not* `subscriptionID`) already exists. + /// + /// **This is a hint, not a guarantee.** CloudKit's wire-level error code + /// is `INTERNAL_ERROR`; the duplicate interpretation is MistKit's + /// inference from the reason string. Use this to surface a helpful + /// suggestion ("a matching subscription may already exist — try + /// `listSubscriptions` to find it") rather than to short-circuit retry + /// logic with certainty. + public var isLikelyDuplicate: Bool { + serverErrorCode == .internalError && reason == Self.duplicateMarker + } + + internal init(from schema: Components.Schemas.SubscriptionOperationFailure) { + self.init(identifier: schema.value2.subscriptionID, common: schema.value1) + } +} + +extension OperationResult +where Success == SubscriptionInfo, Target == SubscriptionTarget { + /// Converts a per-item entry from a `subscriptions/modify` response. + /// + /// Returns `nil` when CloudKit echoes a deleted subscription as a bare + /// `{ subscriptionID }` with no `subscriptionType` — a deletion + /// acknowledgement, not a result, so callers skip it rather than treating + /// the missing type as a conversion failure. + internal init?( + from item: Components.Schemas.SubscriptionsModifyResponse.subscriptionsPayloadPayload + ) throws(ConversionError) { + switch item { + case .SubscriptionOperationFailure(let failure): + self = .failure(OperationFailure(from: failure)) + case .Subscription(let subscription): + guard subscription.subscriptionType != nil else { + return nil + } + self = .success(try SubscriptionInfo(from: subscription)) + } + } +} diff --git a/Sources/MistKit/Models/Subscriptions/NotificationInfo.swift b/Sources/MistKit/Models/Subscriptions/NotificationInfo.swift new file mode 100644 index 00000000..b1731b97 --- /dev/null +++ b/Sources/MistKit/Models/Subscriptions/NotificationInfo.swift @@ -0,0 +1,133 @@ +// +// NotificationInfo.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 MistKitOpenAPI + +// `Bool?` carries an intentional tri-state on these notification flags: +// `nil` means "don't send this field; let CloudKit apply its default" +// (`shouldBadge=false`, `shouldSendContentAvailable=false`). Defaulting +// to non-optional would force callers to pick a wire-level value when +// they really want server-side defaults. +// swiftlint:disable discouraged_optional_boolean + +/// How CloudKit shapes the push notification produced by a subscription. +/// +/// Mirrors `CloudKit.NotificationInfo` from the CloudKit JS reference. +/// Every field is optional — CloudKit applies its own defaults +/// (`shouldBadge=false`, `shouldSendContentAvailable=false`, no alert +/// body) when a field is absent. Pass only the fields you want the +/// server to honor. +public struct NotificationInfo: Codable, Hashable, Sendable { + /// The text of the alert message. + public let alertBody: String? + /// A key to a localized alert message. + public let alertLocalizationKey: String? + /// Strings that appear as variables if ``alertLocalizationKey`` is a + /// format specifier. + public let alertLocalizationArgs: [String]? + /// A key to the localized title of the alert's action button. + public let alertActionLocalizationKey: String? + /// The filename of the image to use as the launch image. + public let alertLaunchImage: String? + /// The filename of the sound to play when the notification arrives. + public let soundName: String? + /// Whether the app icon's badge should be incremented. Server default + /// is `false` when this is `nil`. + public let shouldBadge: Bool? + /// Whether the notification should mark new content as available + /// (silent background fetch). Server default is `false` when this + /// is `nil`. + public let shouldSendContentAvailable: Bool? + /// Names of record fields whose values should be included in the + /// notification payload. + public let additionalFields: [String]? + /// The notification category (UN actionable category identifier). + public let category: String? + + /// Creates a new `NotificationInfo`. Every parameter is optional — + /// omit anything you want CloudKit to default. + public init( + alertBody: String? = nil, + alertLocalizationKey: String? = nil, + alertLocalizationArgs: [String]? = nil, + alertActionLocalizationKey: String? = nil, + alertLaunchImage: String? = nil, + soundName: String? = nil, + shouldBadge: Bool? = nil, + shouldSendContentAvailable: Bool? = nil, + additionalFields: [String]? = nil, + category: String? = nil + ) { + self.alertBody = alertBody + self.alertLocalizationKey = alertLocalizationKey + self.alertLocalizationArgs = alertLocalizationArgs + self.alertActionLocalizationKey = alertActionLocalizationKey + self.alertLaunchImage = alertLaunchImage + self.soundName = soundName + self.shouldBadge = shouldBadge + self.shouldSendContentAvailable = shouldSendContentAvailable + self.additionalFields = additionalFields + self.category = category + } +} + +// swiftlint:enable discouraged_optional_boolean + +// MARK: - Internal Conversion +extension NotificationInfo { + internal var schema: Components.Schemas.NotificationInfo { + Components.Schemas.NotificationInfo( + alertBody: alertBody, + alertLocalizationKey: alertLocalizationKey, + alertLocalizationArgs: alertLocalizationArgs, + alertActionLocalizationKey: alertActionLocalizationKey, + alertLaunchImage: alertLaunchImage, + soundName: soundName, + shouldBadge: shouldBadge, + shouldSendContentAvailable: shouldSendContentAvailable, + additionalFields: additionalFields, + category: category + ) + } + + internal init(from schema: Components.Schemas.NotificationInfo) { + self.init( + alertBody: schema.alertBody, + alertLocalizationKey: schema.alertLocalizationKey, + alertLocalizationArgs: schema.alertLocalizationArgs, + alertActionLocalizationKey: schema.alertActionLocalizationKey, + alertLaunchImage: schema.alertLaunchImage, + soundName: schema.soundName, + shouldBadge: schema.shouldBadge, + shouldSendContentAvailable: schema.shouldSendContentAvailable, + additionalFields: schema.additionalFields, + category: schema.category + ) + } +} diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift new file mode 100644 index 00000000..ee0bb8c1 --- /dev/null +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift @@ -0,0 +1,126 @@ +// +// SubscriptionFireEvents.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 MistKitOpenAPI + +/// The set of record-change events that cause a query subscription +/// to fire a push notification. +/// +/// Mirrors native `CKQuerySubscriptionOptions` (minus `firesOnce`, +/// which MistKit models as a separate `Bool?` to match the CloudKit +/// Web Services wire fields `firesOn` and `firesOnce`). +/// +/// Set semantics matches CloudKit's behavior: two query subscriptions +/// sharing `(recordType, firesOn)` collide regardless of +/// `subscriptionID` (see GH brightdigit/MistKit#387), and the wire +/// format is JSON `["create","update","delete"]`. +public struct SubscriptionFireEvents: OptionSet, Sendable, Hashable { + // MARK: - Public + + public let rawValue: Int + + /// Fire when a matching record is created. + public static let create = SubscriptionFireEvents(rawValue: 1 << 0) + /// Fire when a matching record is updated. + public static let update = SubscriptionFireEvents(rawValue: 1 << 1) + /// Fire when a matching record is deleted. + public static let delete = SubscriptionFireEvents(rawValue: 1 << 2) + + /// All three record-change events. + public static let all: SubscriptionFireEvents = [.create, .update, .delete] + + // MARK: - Lifecycle + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} + +// MARK: - Codable (wire format: array of strings) + +extension SubscriptionFireEvents: Codable { + private static let createWire = "create" + private static let updateWire = "update" + private static let deleteWire = "delete" + + public init(from decoder: any Decoder) throws { + let strings = try [String](from: decoder) + var events: SubscriptionFireEvents = [] + for string in strings { + switch string { + case Self.createWire: events.insert(.create) + case Self.updateWire: events.insert(.update) + case Self.deleteWire: events.insert(.delete) + default: + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unknown SubscriptionFireEvent wire value: \(string)" + ) + ) + } + } + self = events + } + + public func encode(to encoder: any Encoder) throws { + var strings: [String] = [] + if contains(.create) { strings.append(Self.createWire) } + if contains(.update) { strings.append(Self.updateWire) } + if contains(.delete) { strings.append(Self.deleteWire) } + try strings.encode(to: encoder) + } +} + +// MARK: - OpenAPI Schema Bridge + +extension SubscriptionFireEvents { + /// The set's wire representation as an array of the OpenAPI-generated + /// per-event enum. Sort order is deterministic (create, update, + /// delete) so encoding is stable for tests and HTTP caches. + internal var schemaValues: [Components.Schemas.Subscription.firesOnPayloadPayload] { + var values: [Components.Schemas.Subscription.firesOnPayloadPayload] = [] + if contains(.create) { values.append(.create) } + if contains(.update) { values.append(.update) } + if contains(.delete) { values.append(.delete) } + return values + } + + internal init(schemaValues: [Components.Schemas.Subscription.firesOnPayloadPayload]) { + var events: SubscriptionFireEvents = [] + for value in schemaValues { + switch value { + case .create: events.insert(.create) + case .update: events.insert(.update) + case .delete: events.insert(.delete) + } + } + self = events + } +} diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift new file mode 100644 index 00000000..4c0e61ed --- /dev/null +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift @@ -0,0 +1,131 @@ +// +// SubscriptionInfo+Schema.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 MistKitOpenAPI + +// MARK: - OpenAPI Schema Bridge + +extension SubscriptionInfo { + /// The OpenAPI schema representation used when sending this + /// subscription in a modify request. + internal var schema: Components.Schemas.Subscription { + let querySchema: Components.Schemas.Query? + let zoneIDSchema: Components.Schemas.ZoneID? + let firesOnSchema: [Components.Schemas.Subscription.firesOnPayloadPayload]? + // swiftlint:disable:next discouraged_optional_boolean + let firesOnceValue: Bool? + // swiftlint:disable:next discouraged_optional_boolean + let zoneWideValue: Bool? + switch kind { + case .query(let watchedQuery, let firesOn, let firesOnce): + querySchema = watchedQuery.schema + zoneIDSchema = nil + firesOnSchema = firesOn.schemaValues + firesOnceValue = firesOnce + zoneWideValue = nil + case .zone(let zoneIDValue): + querySchema = nil + zoneIDSchema = Components.Schemas.ZoneID(from: zoneIDValue) + firesOnSchema = nil + firesOnceValue = nil + zoneWideValue = nil + case .database: + // Database-scoped subscription: omit zoneID, set zoneWide=true. + querySchema = nil + zoneIDSchema = nil + firesOnSchema = nil + firesOnceValue = nil + zoneWideValue = true + } + return Components.Schemas.Subscription( + subscriptionID: subscriptionID, + subscriptionType: subscriptionType.schemaValue, + query: querySchema, + zoneID: zoneIDSchema, + zoneWide: zoneWideValue, + firesOn: firesOnSchema, + firesOnce: firesOnceValue, + notificationInfo: notificationInfo?.schema + ) + } + + /// Convert a decoded `Subscription` payload into a `SubscriptionInfo`. + /// + /// A missing `subscriptionID`/`subscriptionType`, a `zoneID` without + /// a `zoneName`, or a `query` subscription with no `firesOn` events + /// is a conversion failure (logged, asserted in DEBUG, and thrown) + /// rather than a silently-dropped subscription. + internal init(from subscription: Components.Schemas.Subscription) throws(ConversionError) { + guard let subscriptionID = subscription.subscriptionID else { + try ConversionError.subscriptionMissingID.reportAndThrow() + } + guard let subscriptionType = subscription.subscriptionType else { + try ConversionError.subscriptionMissingType.reportAndThrow() + } + + let kind: Kind + switch SubscriptionType(from: subscriptionType) { + case .query: + // Preserve prior behavior: a `query` subscription payload that + // omits the query body still decodes (callers can inspect the + // empty `Query` and decide whether to treat it as invalid). + let query = subscription.query.map(Query.init) ?? Query(Components.Schemas.Query()) + let firesOn = SubscriptionFireEvents(schemaValues: subscription.firesOn ?? []) + guard !firesOn.isEmpty else { + try ConversionError.subscriptionQueryMissingFiresOn.reportAndThrow() + } + kind = .query(query, firesOn: firesOn, firesOnce: subscription.firesOnce) + case .zone: + // CloudKit collapses zone- and database-scoped subscriptions into + // `subscriptionType: zone`; `zoneWide: true` is the + // database-subscription marker. Surface them as separate `Kind` + // cases so callers don't have to know about the wire collapse. + if subscription.zoneWide == true { + kind = .database + } else { + guard let payload = subscription.zoneID else { + // A non-wide zone subscription without a zoneID is invalid; + // surface as missing-zone-name failure for parity with + // existing behavior. + try ConversionError.zoneMissingName.reportAndThrow() + } + guard let zoneName = payload.zoneName else { + try ConversionError.zoneMissingName.reportAndThrow() + } + kind = .zone(ZoneID(zoneName: zoneName, ownerName: payload.ownerName)) + } + } + + self.init( + subscriptionID: subscriptionID, + kind: kind, + notificationInfo: subscription.notificationInfo.map(NotificationInfo.init(from:)) + ) + } +} diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift index 9b5cf829..63531994 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift @@ -27,116 +27,274 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import MistKitOpenAPI - -/// A CloudKit subscription: the change trigger that produces push notifications. +/// A CloudKit subscription: the change trigger that produces push +/// notifications. /// -/// Returned by ``CloudKitService/listSubscriptions(database:)`` and -/// ``CloudKitService/lookupSubscriptions(ids:database:)``, and constructed to -/// create new subscriptions via -/// ``CloudKitService/modifySubscriptions(_:database:)``. +/// A subscription is one of three kinds — query, zone, or database — +/// mirroring the native CKSubscription subclasses. Variant-specific +/// data (the watched query, `firesOn` set, `firesOnce` flag, watched +/// zone) lives inside ``Kind``. Only `subscriptionID` and +/// `notificationInfo` are shared on the outer struct. /// -/// Use ``query(subscriptionID:recordType:filters:sortBy:firesOn:)`` or -/// ``zone(subscriptionID:zoneID:firesOn:)`` to build one; CloudKit Web Services -/// requires the caller to supply the `subscriptionID`. -public struct SubscriptionInfo: Codable, Sendable { +/// Use ``query(subscriptionID:recordType:filters:sortBy:firesOn:firesOnce:notificationInfo:)``, +/// ``zone(subscriptionID:zoneID:notificationInfo:)``, or +/// ``database(subscriptionID:notificationInfo:)`` to build one; +/// CloudKit Web Services requires the caller to supply the +/// `subscriptionID`. +// `Bool?` is used intentionally — `nil` means "don't set the field; +// let CloudKit apply its server-side default" (e.g. `firesOnce`). +// swiftlint:disable discouraged_optional_boolean + +public struct SubscriptionInfo: Sendable { + /// The variant-specific data for a subscription. Mirrors the three + /// native CloudKit subscription classes: + /// - ``query(_:firesOn:firesOnce:)`` ⇄ `CKQuerySubscription` + /// - ``zone(_:)`` ⇄ `CKRecordZoneSubscription` + /// - ``database`` ⇄ `CKDatabaseSubscription` + /// + /// `firesOn` and `firesOnce` only live on the `.query` case because + /// native CKSubscription only exposes them on `CKQuerySubscription`; + /// zone and database subscriptions fire on any change in their scope. + /// + /// CloudKit Web Services only ships two `subscriptionType` values + /// over the wire — `query` and `zone`. The `.database` case + /// serializes as `subscriptionType: "zone"` with `zoneWide: true` + /// and no specific `zoneID`. CloudKit serves database subscriptions + /// only against private and shared databases. + public enum Kind: Sendable { + /// A query subscription that fires when records matching the + /// embedded query change. `firesOn` must be non-empty — a query + /// subscription with no fire events would never trigger; the + /// public factory and ``SubscriptionInfo/init(subscriptionID:kind:notificationInfo:)`` + /// `precondition`-trap empty sets, and the schema/Codable decoders + /// throw on the same. + case query(Query, firesOn: SubscriptionFireEvents, firesOnce: Bool? = nil) + + /// A zone subscription that fires when any record in the named + /// zone changes. + case zone(ZoneID) + + /// A database subscription that fires when records in *any* zone + /// in the database change (wire: `subscriptionType: "zone"`, + /// `zoneWide: true`). Private and shared databases only. + case database + } + /// The client-supplied unique identifier for the subscription. public let subscriptionID: String - /// Whether this is a `query` or `zone` subscription. - public let subscriptionType: SubscriptionType - /// The watched query, for `query` subscriptions. - public let query: SubscriptionQuery? - /// The watched zone, for `zone` subscriptions. - public let zoneID: ZoneID? - /// The record-change events that trigger a push. - public let firesOn: [SubscriptionFireEvent] - - /// The OpenAPI schema representation used when sending this subscription in a - /// modify request. - internal var schema: Components.Schemas.Subscription { - Components.Schemas.Subscription( - subscriptionID: self.subscriptionID, - subscriptionType: self.subscriptionType.schemaValue, - query: self.query?.schema, - zoneID: self.zoneID.map { Components.Schemas.ZoneID(from: $0) }, - firesOn: self.firesOn.isEmpty ? nil : self.firesOn.map(\.schemaValue) - ) - } - /// Initialize a subscription. + /// The variant — query, zone, or database — and its variant-specific + /// data. + public let kind: Kind + + /// How CloudKit should shape the push notification when this + /// subscription fires. `nil` means "use CloudKit defaults". + public let notificationInfo: NotificationInfo? + + /// Creates a subscription explicitly from a ``Kind`` and the shared + /// properties. Traps if ``Kind/query(_:firesOn:firesOnce:)`` carries + /// an empty `firesOn` set. public init( subscriptionID: String, - subscriptionType: SubscriptionType, - query: SubscriptionQuery? = nil, - zoneID: ZoneID? = nil, - firesOn: [SubscriptionFireEvent] = [] + kind: Kind, + notificationInfo: NotificationInfo? = nil ) { - self.subscriptionID = subscriptionID - self.subscriptionType = subscriptionType - self.query = query - self.zoneID = zoneID - self.firesOn = firesOn - } - - /// Convert a decoded `Subscription` payload into a `SubscriptionInfo`. - /// - /// A missing `subscriptionID`/`subscriptionType`, or a `zoneID` without a - /// `zoneName`, is a conversion failure (logged, asserted in DEBUG, and thrown) - /// rather than a silently-dropped subscription. - internal init(from subscription: Components.Schemas.Subscription) throws(ConversionError) { - guard let subscriptionID = subscription.subscriptionID else { - try ConversionError.subscriptionMissingID.reportAndThrow() - } - guard let subscriptionType = subscription.subscriptionType else { - try ConversionError.subscriptionMissingType.reportAndThrow() + if case .query(_, let firesOn, _) = kind { + precondition(!firesOn.isEmpty, "Query subscription firesOn must not be empty.") } - - let zoneID: ZoneID? - if let payload = subscription.zoneID { - guard let zoneName = payload.zoneName else { - try ConversionError.zoneMissingName.reportAndThrow() - } - zoneID = ZoneID(zoneName: zoneName, ownerName: payload.ownerName) - } else { - zoneID = nil - } - - self.init( - subscriptionID: subscriptionID, - subscriptionType: SubscriptionType(from: subscriptionType), - query: subscription.query.map(SubscriptionQuery.init), - zoneID: zoneID, - firesOn: subscription.firesOn?.map(SubscriptionFireEvent.init(from:)) ?? [] - ) + self.subscriptionID = subscriptionID + self.kind = kind + self.notificationInfo = notificationInfo } - /// Build a `query` subscription that fires when matching records change. + /// Build a `query` subscription that fires when matching records + /// change. `firesOn` must be non-empty — passing `[]` traps. public static func query( subscriptionID: String, recordType: String, filters: [QueryFilter] = [], sortBy: [QuerySort] = [], - firesOn: [SubscriptionFireEvent] + firesOn: SubscriptionFireEvents, + firesOnce: Bool? = nil, + notificationInfo: NotificationInfo? = nil ) -> SubscriptionInfo { SubscriptionInfo( subscriptionID: subscriptionID, - subscriptionType: .query, - query: SubscriptionQuery(recordType: recordType, filters: filters, sortBy: sortBy), - firesOn: firesOn + kind: .query( + Query(recordType: recordType, filters: filters, sortBy: sortBy), + firesOn: firesOn, + firesOnce: firesOnce + ), + notificationInfo: notificationInfo ) } - /// Build a `zone` subscription that fires when any record in a zone changes. + /// Build a `zone` subscription that fires when any record in a zone + /// changes. For a database-wide subscription, use + /// ``database(subscriptionID:notificationInfo:)`` instead. public static func zone( subscriptionID: String, zoneID: ZoneID, - firesOn: [SubscriptionFireEvent] = [] + notificationInfo: NotificationInfo? = nil + ) -> SubscriptionInfo { + SubscriptionInfo( + subscriptionID: subscriptionID, + kind: .zone(zoneID), + notificationInfo: notificationInfo + ) + } + + /// Build a `database` subscription that fires when records in *any* + /// zone in the current database change. Wire representation is + /// `subscriptionType: "zone"` with `zoneWide: true`. Private and + /// shared databases only — CloudKit rejects database subscriptions + /// against the public database. + public static func database( + subscriptionID: String, + notificationInfo: NotificationInfo? = nil ) -> SubscriptionInfo { SubscriptionInfo( subscriptionID: subscriptionID, - subscriptionType: .zone, - zoneID: zoneID, - firesOn: firesOn + kind: .database, + notificationInfo: notificationInfo ) } } + +// MARK: - Derived Accessors + +extension SubscriptionInfo { + /// The wire-level `subscriptionType` — `.query` for query + /// subscriptions, `.zone` for both zone and database subscriptions + /// (CloudKit Web Services has no separate `database` wire value). + /// Derived from ``kind``. + public var subscriptionType: SubscriptionType { + switch kind { + case .query: return .query + case .zone, .database: return .zone + } + } + + /// The watched query, when this is a `.query` subscription. + /// `nil` for zone and database subscriptions. + public var query: Query? { + if case .query(let query, _, _) = kind { + return query + } + return nil + } + + /// The set of record-change events that fire this subscription + /// (query subscriptions only). Empty for zone and database + /// subscriptions, which fire on any change in their scope. + public var firesOn: SubscriptionFireEvents { + if case .query(_, let firesOn, _) = kind { + return firesOn + } + return [] + } + + /// The `firesOnce` flag for a `.query` subscription. `nil` for zone + /// and database subscriptions (the wire field doesn't apply) or when + /// not set. + public var firesOnce: Bool? { + if case .query(_, _, let firesOnce) = kind { + return firesOnce + } + return nil + } + + /// The watched zone, when this is a `.zone` subscription. + /// `nil` for query and database subscriptions. + public var zoneID: ZoneID? { + if case .zone(let zoneID) = kind { + return zoneID + } + return nil + } + + /// `true` for a `.database` subscription (wire: `zoneWide: true`), + /// `nil` otherwise — query and specific-zone subscriptions do not + /// carry the flag. + public var zoneWide: Bool? { + if case .database = kind { + return true + } + return nil + } +} + +// MARK: - Codable (wire-compatible flat encoding) + +extension SubscriptionInfo: Codable { + private enum CodingKeys: String, CodingKey { + case subscriptionID + case subscriptionType + case query + case zoneID + case zoneWide + case firesOn + case firesOnce + case notificationInfo + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.subscriptionID = try container.decode(String.self, forKey: .subscriptionID) + self.notificationInfo = + try container.decodeIfPresent(NotificationInfo.self, forKey: .notificationInfo) + + let type = try container.decode(SubscriptionType.self, forKey: .subscriptionType) + switch type { + case .query: + let query = try container.decode(Query.self, forKey: .query) + let firesOn = + try container.decodeIfPresent(SubscriptionFireEvents.self, forKey: .firesOn) ?? [] + guard !firesOn.isEmpty else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath + [CodingKeys.firesOn], + debugDescription: + "Query subscription missing or empty firesOn — must declare " + + "at least one of [create, update, delete]." + ) + ) + } + let firesOnce = try container.decodeIfPresent(Bool.self, forKey: .firesOnce) + self.kind = .query(query, firesOn: firesOn, firesOnce: firesOnce) + case .zone: + // CloudKit collapses both "watch one zone" and "watch the whole + // database" into `subscriptionType: zone`; `zoneWide: true` is + // the database-subscription marker. Read it as `.database` so the + // domain side preserves the native CKSubscription trichotomy. + let wide = try container.decodeIfPresent(Bool.self, forKey: .zoneWide) ?? false + if wide { + self.kind = .database + } else { + let zoneID = try container.decode(ZoneID.self, forKey: .zoneID) + self.kind = .zone(zoneID) + } + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(subscriptionID, forKey: .subscriptionID) + try container.encode(subscriptionType, forKey: .subscriptionType) + try container.encodeIfPresent(notificationInfo, forKey: .notificationInfo) + switch kind { + case .query(let query, let firesOn, let firesOnce): + try container.encode(query, forKey: .query) + try container.encode(firesOn, forKey: .firesOn) + try container.encodeIfPresent(firesOnce, forKey: .firesOnce) + case .zone(let zoneID): + try container.encode(zoneID, forKey: .zoneID) + case .database: + try container.encode(true, forKey: .zoneWide) + } + } +} + +// swiftlint:enable discouraged_optional_boolean + +// The OpenAPI schema bridge lives in `SubscriptionInfo+Schema.swift`. diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift index 342372ed..62d0d7a7 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift @@ -27,54 +27,15 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import MistKitOpenAPI - -/// The query a `query`-type subscription watches. -/// -/// This reuses the exact same query model as a record query — a `recordType` -/// plus the same ``QueryFilter`` / ``QuerySort`` building blocks — so a -/// subscription's predicate is expressed identically to a one-off query passed -/// to `CloudKitService.queryRecords`. -public struct SubscriptionQuery: Codable, Sendable { - // MARK: - Internal - - internal let schema: Components.Schemas.Query - - // MARK: - Public - - /// The record type this query watches, as returned by CloudKit. - public var recordType: String? { - self.schema.recordType - } - - // MARK: - Lifecycle - - internal init(_ schema: Components.Schemas.Query) { - self.schema = schema - } - - /// Build a subscription query. - /// - Parameters: - /// - recordType: The record type the subscription watches. - /// - filters: Optional predicate filters (reusing ``QueryFilter``). - /// - sortBy: Optional sort descriptors (reusing ``QuerySort``). - public init( - recordType: String, - filters: [QueryFilter] = [], - sortBy: [QuerySort] = [] - ) { - self.schema = Components.Schemas.Query( - recordType: recordType, - filterBy: filters.isEmpty ? nil : filters.map(\.filter), - sortBy: sortBy.isEmpty ? nil : sortBy.map(\.sort) - ) - } - - public init(from decoder: any Decoder) throws { - self.schema = try Components.Schemas.Query(from: decoder) - } - - public func encode(to encoder: any Encoder) throws { - try self.schema.encode(to: encoder) - } -} +/// Deprecated alias for ``Query``. MistKit unified its query +/// representation so the same value powers +/// ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:database:)`` +/// and ``SubscriptionInfo/Kind/query(_:)``. +@available( + *, + deprecated, + renamed: "Query", + message: + "MistKit unified its query type — use `Query` for both queryRecords and SubscriptionInfo.Kind.query." +) +public typealias SubscriptionQuery = Query diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index a675875e..1d7fe758 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -1576,8 +1576,13 @@ public enum Components { case subscription } } + /// A CloudKit subscription — a persistent server-side trigger that produces push notifications when matching changes occur. Mirrors `CloudKit.Subscription` from the CloudKit JS reference. + /// + /// /// - Remark: Generated from `#/components/schemas/Subscription`. public struct Subscription: Codable, Hashable, Sendable { + /// Caller-supplied unique identifier for the subscription. + /// /// - Remark: Generated from `#/components/schemas/Subscription/subscriptionID`. public var subscriptionID: Swift.String? /// - Remark: Generated from `#/components/schemas/Subscription/subscriptionType`. @@ -1591,43 +1596,171 @@ public enum Components { public var query: Components.Schemas.Query? /// - Remark: Generated from `#/components/schemas/Subscription/zoneID`. public var zoneID: Components.Schemas.ZoneID? + /// Zone subscriptions only. If `true`, the subscription watches *every zone* in the database (the wire representation of a native `CKDatabaseSubscription`); if `false`/absent, only the zone identified by `zoneID` is watched. Only valid against private and shared databases. Default `false`. + /// + /// + /// - Remark: Generated from `#/components/schemas/Subscription/zoneWide`. + public var zoneWide: Swift.Bool? /// - Remark: Generated from `#/components/schemas/Subscription/firesOnPayload`. @frozen public enum firesOnPayloadPayload: String, Codable, Hashable, Sendable, CaseIterable { case create = "create" case update = "update" case delete = "delete" } + /// The record-change events that trigger a push (e.g. `[create, update]`). CloudKit treats the exact set as the subscription's uniqueness key — two subscriptions on the same `(recordType, firesOn)` tuple collide regardless of `subscriptionID`. + /// + /// /// - Remark: Generated from `#/components/schemas/Subscription/firesOn`. public typealias firesOnPayload = [Components.Schemas.Subscription.firesOnPayloadPayload] + /// The record-change events that trigger a push (e.g. `[create, update]`). CloudKit treats the exact set as the subscription's uniqueness key — two subscriptions on the same `(recordType, firesOn)` tuple collide regardless of `subscriptionID`. + /// + /// /// - Remark: Generated from `#/components/schemas/Subscription/firesOn`. public var firesOn: Components.Schemas.Subscription.firesOnPayload? + /// If `true`, the subscription is destroyed after producing its first notification. Default `false`. + /// + /// + /// - Remark: Generated from `#/components/schemas/Subscription/firesOnce`. + public var firesOnce: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/Subscription/notificationInfo`. + public var notificationInfo: Components.Schemas.NotificationInfo? /// Creates a new `Subscription`. /// /// - Parameters: - /// - subscriptionID: + /// - subscriptionID: Caller-supplied unique identifier for the subscription. /// - subscriptionType: /// - query: /// - zoneID: - /// - firesOn: + /// - zoneWide: Zone subscriptions only. If `true`, the subscription watches *every zone* in the database (the wire representation of a native `CKDatabaseSubscription`); if `false`/absent, only the zone identified by `zoneID` is watched. Only valid against private and shared databases. Default `false`. + /// - firesOn: The record-change events that trigger a push (e.g. `[create, update]`). CloudKit treats the exact set as the subscription's uniqueness key — two subscriptions on the same `(recordType, firesOn)` tuple collide regardless of `subscriptionID`. + /// - firesOnce: If `true`, the subscription is destroyed after producing its first notification. Default `false`. + /// - notificationInfo: public init( subscriptionID: Swift.String? = nil, subscriptionType: Components.Schemas.Subscription.subscriptionTypePayload? = nil, query: Components.Schemas.Query? = nil, zoneID: Components.Schemas.ZoneID? = nil, - firesOn: Components.Schemas.Subscription.firesOnPayload? = nil + zoneWide: Swift.Bool? = nil, + firesOn: Components.Schemas.Subscription.firesOnPayload? = nil, + firesOnce: Swift.Bool? = nil, + notificationInfo: Components.Schemas.NotificationInfo? = nil ) { self.subscriptionID = subscriptionID self.subscriptionType = subscriptionType self.query = query self.zoneID = zoneID + self.zoneWide = zoneWide self.firesOn = firesOn + self.firesOnce = firesOnce + self.notificationInfo = notificationInfo } public enum CodingKeys: String, CodingKey { case subscriptionID case subscriptionType case query case zoneID + case zoneWide case firesOn + case firesOnce + case notificationInfo + } + } + /// How CloudKit shapes the push notification produced by a subscription. Mirrors `CloudKit.NotificationInfo` from the CloudKit JS reference. + /// + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo`. + public struct NotificationInfo: Codable, Hashable, Sendable { + /// The text of the alert message. + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/alertBody`. + public var alertBody: Swift.String? + /// A key to a localized alert message. + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/alertLocalizationKey`. + public var alertLocalizationKey: Swift.String? + /// Strings that appear as variables if `alertLocalizationKey` is a format specifier. + /// + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/alertLocalizationArgs`. + public var alertLocalizationArgs: [Swift.String]? + /// A key to the localized title of the alert's action button. + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/alertActionLocalizationKey`. + public var alertActionLocalizationKey: Swift.String? + /// The filename of the image to use as the launch image. + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/alertLaunchImage`. + public var alertLaunchImage: Swift.String? + /// The filename of the sound to play when the notification arrives. + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/soundName`. + public var soundName: Swift.String? + /// Whether the app icon's badge should be incremented. Default `false`. + /// + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/shouldBadge`. + public var shouldBadge: Swift.Bool? + /// Whether the notification should mark new content as available (silent background fetch). Default `false`. + /// + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/shouldSendContentAvailable`. + public var shouldSendContentAvailable: Swift.Bool? + /// Names of record fields whose values should be included in the notification payload. + /// + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/additionalFields`. + public var additionalFields: [Swift.String]? + /// The notification category (UN actionable category identifier). + /// + /// - Remark: Generated from `#/components/schemas/NotificationInfo/category`. + public var category: Swift.String? + /// Creates a new `NotificationInfo`. + /// + /// - Parameters: + /// - alertBody: The text of the alert message. + /// - alertLocalizationKey: A key to a localized alert message. + /// - alertLocalizationArgs: Strings that appear as variables if `alertLocalizationKey` is a format specifier. + /// - alertActionLocalizationKey: A key to the localized title of the alert's action button. + /// - alertLaunchImage: The filename of the image to use as the launch image. + /// - soundName: The filename of the sound to play when the notification arrives. + /// - shouldBadge: Whether the app icon's badge should be incremented. Default `false`. + /// - shouldSendContentAvailable: Whether the notification should mark new content as available (silent background fetch). Default `false`. + /// - additionalFields: Names of record fields whose values should be included in the notification payload. + /// - category: The notification category (UN actionable category identifier). + public init( + alertBody: Swift.String? = nil, + alertLocalizationKey: Swift.String? = nil, + alertLocalizationArgs: [Swift.String]? = nil, + alertActionLocalizationKey: Swift.String? = nil, + alertLaunchImage: Swift.String? = nil, + soundName: Swift.String? = nil, + shouldBadge: Swift.Bool? = nil, + shouldSendContentAvailable: Swift.Bool? = nil, + additionalFields: [Swift.String]? = nil, + category: Swift.String? = nil + ) { + self.alertBody = alertBody + self.alertLocalizationKey = alertLocalizationKey + self.alertLocalizationArgs = alertLocalizationArgs + self.alertActionLocalizationKey = alertActionLocalizationKey + self.alertLaunchImage = alertLaunchImage + self.soundName = soundName + self.shouldBadge = shouldBadge + self.shouldSendContentAvailable = shouldSendContentAvailable + self.additionalFields = additionalFields + self.category = category + } + public enum CodingKeys: String, CodingKey { + case alertBody + case alertLocalizationKey + case alertLocalizationArgs + case alertActionLocalizationKey + case alertLaunchImage + case soundName + case shouldBadge + case shouldSendContentAvailable + case additionalFields + case category } } /// - Remark: Generated from `#/components/schemas/QueryResponse`. @@ -1966,80 +2099,46 @@ public enum Components { /// /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure`. public struct SubscriptionOperationFailure: Codable, Hashable, Sendable { - /// The identifier of the subscription the operation failed on. - /// - /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/subscriptionID`. - public var subscriptionID: Swift.String - /// The code for the error that occurred. - /// - /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/serverErrorCode`. - @frozen public enum serverErrorCodePayload: String, Codable, Hashable, Sendable, CaseIterable { - case ACCESS_DENIED = "ACCESS_DENIED" - case ATOMIC_ERROR = "ATOMIC_ERROR" - case AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" - case AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED" - case BAD_REQUEST = "BAD_REQUEST" - case CONFLICT = "CONFLICT" - case EXISTS = "EXISTS" - case INTERNAL_ERROR = "INTERNAL_ERROR" - case NOT_FOUND = "NOT_FOUND" - case QUOTA_EXCEEDED = "QUOTA_EXCEEDED" - case THROTTLED = "THROTTLED" - case TRY_AGAIN_LATER = "TRY_AGAIN_LATER" - case VALIDATING_REFERENCE_ERROR = "VALIDATING_REFERENCE_ERROR" - case ZONE_NOT_FOUND = "ZONE_NOT_FOUND" + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/value1`. + public var value1: Components.Schemas.OperationFailureCommon + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/value2`. + public struct Value2Payload: Codable, Hashable, Sendable { + /// The identifier of the subscription the operation failed on. + /// + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/value2/subscriptionID`. + public var subscriptionID: Swift.String + /// Creates a new `Value2Payload`. + /// + /// - Parameters: + /// - subscriptionID: The identifier of the subscription the operation failed on. + public init(subscriptionID: Swift.String) { + self.subscriptionID = subscriptionID + } + public enum CodingKeys: String, CodingKey { + case subscriptionID + } } - /// The code for the error that occurred. - /// - /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/serverErrorCode`. - public var serverErrorCode: Components.Schemas.SubscriptionOperationFailure.serverErrorCodePayload - /// A string indicating the reason for the error. - /// - /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/reason`. - public var reason: Swift.String? - /// Suggested seconds to wait before retrying. Absent if not retryable. - /// - /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/retryAfter`. - public var retryAfter: Swift.Int? - /// A unique identifier for this error. - /// - /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/uuid`. - public var uuid: Swift.String? - /// Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. - /// - /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/redirectURL`. - public var redirectURL: Swift.String? + /// - Remark: Generated from `#/components/schemas/SubscriptionOperationFailure/value2`. + public var value2: Components.Schemas.SubscriptionOperationFailure.Value2Payload /// Creates a new `SubscriptionOperationFailure`. /// /// - Parameters: - /// - subscriptionID: The identifier of the subscription the operation failed on. - /// - serverErrorCode: The code for the error that occurred. - /// - reason: A string indicating the reason for the error. - /// - retryAfter: Suggested seconds to wait before retrying. Absent if not retryable. - /// - uuid: A unique identifier for this error. - /// - redirectURL: Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. + /// - value1: + /// - value2: public init( - subscriptionID: Swift.String, - serverErrorCode: Components.Schemas.SubscriptionOperationFailure.serverErrorCodePayload, - reason: Swift.String? = nil, - retryAfter: Swift.Int? = nil, - uuid: Swift.String? = nil, - redirectURL: Swift.String? = nil + value1: Components.Schemas.OperationFailureCommon, + value2: Components.Schemas.SubscriptionOperationFailure.Value2Payload ) { - self.subscriptionID = subscriptionID - self.serverErrorCode = serverErrorCode - self.reason = reason - self.retryAfter = retryAfter - self.uuid = uuid - self.redirectURL = redirectURL + self.value1 = value1 + self.value2 = value2 } - public enum CodingKeys: String, CodingKey { - case subscriptionID - case serverErrorCode - case reason - case retryAfter - case uuid - case redirectURL + public init(from decoder: any Decoder) throws { + self.value1 = try .init(from: decoder) + self.value2 = try .init(from: decoder) + } + public func encode(to encoder: any Encoder) throws { + try self.value1.encode(to: encoder) + try self.value2.encode(to: encoder) } } /// - Remark: Generated from `#/components/schemas/SubscriptionsModifyResponse`. @@ -2426,75 +2525,72 @@ public enum Components { case webcourierURL } } - /// Per-record error returned inline in the `records` array of a 200 - /// modify/lookup response. Identifies the record that failed and why. - /// Distinct from `ErrorResponse`, which is the body of a top-level 4xx/5xx - /// HTTP failure. Note CloudKit does not echo `recordType` on a record error. + /// The CloudKit server error code returned in a per-item failure entry + /// (record or subscription) inline in a 200 modify/lookup response. + /// Shared by `RecordOperationFailure` and `SubscriptionOperationFailure` + /// via `OperationFailureCommon`. Distinct from `ErrorResponse`'s + /// `serverErrorCode`, which carries a broader set of codes for top-level + /// 4xx/5xx HTTP failures. /// /// - /// - Remark: Generated from `#/components/schemas/RecordOperationFailure`. - public struct RecordOperationFailure: Codable, Hashable, Sendable { - /// The name of the record that the operation failed on. - /// - /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/recordName`. - public var recordName: Swift.String - /// The code for the error that occurred. - /// - /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/serverErrorCode`. - @frozen public enum serverErrorCodePayload: String, Codable, Hashable, Sendable, CaseIterable { - case ACCESS_DENIED = "ACCESS_DENIED" - case ATOMIC_ERROR = "ATOMIC_ERROR" - case AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" - case AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED" - case BAD_REQUEST = "BAD_REQUEST" - case CONFLICT = "CONFLICT" - case EXISTS = "EXISTS" - case INTERNAL_ERROR = "INTERNAL_ERROR" - case NOT_FOUND = "NOT_FOUND" - case QUOTA_EXCEEDED = "QUOTA_EXCEEDED" - case THROTTLED = "THROTTLED" - case TRY_AGAIN_LATER = "TRY_AGAIN_LATER" - case VALIDATING_REFERENCE_ERROR = "VALIDATING_REFERENCE_ERROR" - case ZONE_NOT_FOUND = "ZONE_NOT_FOUND" - } - /// The code for the error that occurred. - /// - /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/serverErrorCode`. - public var serverErrorCode: Components.Schemas.RecordOperationFailure.serverErrorCodePayload + /// - Remark: Generated from `#/components/schemas/OperationFailureServerErrorCode`. + @frozen public enum OperationFailureServerErrorCode: String, Codable, Hashable, Sendable, CaseIterable { + case ACCESS_DENIED = "ACCESS_DENIED" + case ATOMIC_ERROR = "ATOMIC_ERROR" + case AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" + case AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED" + case BAD_REQUEST = "BAD_REQUEST" + case CONFLICT = "CONFLICT" + case EXISTS = "EXISTS" + case INTERNAL_ERROR = "INTERNAL_ERROR" + case NOT_FOUND = "NOT_FOUND" + case QUOTA_EXCEEDED = "QUOTA_EXCEEDED" + case THROTTLED = "THROTTLED" + case TRY_AGAIN_LATER = "TRY_AGAIN_LATER" + case VALIDATING_REFERENCE_ERROR = "VALIDATING_REFERENCE_ERROR" + case ZONE_NOT_FOUND = "ZONE_NOT_FOUND" + } + /// Shared fields of a per-item failure entry returned inline in a 200 + /// modify/lookup response. Composed into `RecordOperationFailure` and + /// `SubscriptionOperationFailure` via `allOf`; each concrete failure + /// type adds its own wire identifier (`recordName` / `subscriptionID`). + /// + /// + /// - Remark: Generated from `#/components/schemas/OperationFailureCommon`. + public struct OperationFailureCommon: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/OperationFailureCommon/serverErrorCode`. + public var serverErrorCode: Components.Schemas.OperationFailureServerErrorCode /// A string indicating the reason for the error. /// - /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/reason`. + /// - Remark: Generated from `#/components/schemas/OperationFailureCommon/reason`. public var reason: Swift.String? /// Suggested seconds to wait before retrying. Absent if not retryable. /// - /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/retryAfter`. + /// - Remark: Generated from `#/components/schemas/OperationFailureCommon/retryAfter`. public var retryAfter: Swift.Int? /// A unique identifier for this error. /// - /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/uuid`. + /// - Remark: Generated from `#/components/schemas/OperationFailureCommon/uuid`. public var uuid: Swift.String? /// Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. /// - /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/redirectURL`. + /// - Remark: Generated from `#/components/schemas/OperationFailureCommon/redirectURL`. public var redirectURL: Swift.String? - /// Creates a new `RecordOperationFailure`. + /// Creates a new `OperationFailureCommon`. /// /// - Parameters: - /// - recordName: The name of the record that the operation failed on. - /// - serverErrorCode: The code for the error that occurred. + /// - serverErrorCode: /// - reason: A string indicating the reason for the error. /// - retryAfter: Suggested seconds to wait before retrying. Absent if not retryable. /// - uuid: A unique identifier for this error. /// - redirectURL: Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. public init( - recordName: Swift.String, - serverErrorCode: Components.Schemas.RecordOperationFailure.serverErrorCodePayload, + serverErrorCode: Components.Schemas.OperationFailureServerErrorCode, reason: Swift.String? = nil, retryAfter: Swift.Int? = nil, uuid: Swift.String? = nil, redirectURL: Swift.String? = nil ) { - self.recordName = recordName self.serverErrorCode = serverErrorCode self.reason = reason self.retryAfter = retryAfter @@ -2502,7 +2598,6 @@ public enum Components { self.redirectURL = redirectURL } public enum CodingKeys: String, CodingKey { - case recordName case serverErrorCode case reason case retryAfter @@ -2510,6 +2605,56 @@ public enum Components { case redirectURL } } + /// Per-record error returned inline in the `records` array of a 200 + /// modify/lookup response. Identifies the record that failed and why. + /// Distinct from `ErrorResponse`, which is the body of a top-level 4xx/5xx + /// HTTP failure. Note CloudKit does not echo `recordType` on a record error. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordOperationFailure`. + public struct RecordOperationFailure: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/value1`. + public var value1: Components.Schemas.OperationFailureCommon + /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/value2`. + public struct Value2Payload: Codable, Hashable, Sendable { + /// The name of the record that the operation failed on. + /// + /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/value2/recordName`. + public var recordName: Swift.String + /// Creates a new `Value2Payload`. + /// + /// - Parameters: + /// - recordName: The name of the record that the operation failed on. + public init(recordName: Swift.String) { + self.recordName = recordName + } + public enum CodingKeys: String, CodingKey { + case recordName + } + } + /// - Remark: Generated from `#/components/schemas/RecordOperationFailure/value2`. + public var value2: Components.Schemas.RecordOperationFailure.Value2Payload + /// Creates a new `RecordOperationFailure`. + /// + /// - Parameters: + /// - value1: + /// - value2: + public init( + value1: Components.Schemas.OperationFailureCommon, + value2: Components.Schemas.RecordOperationFailure.Value2Payload + ) { + self.value1 = value1 + self.value2 = value2 + } + public init(from decoder: any Decoder) throws { + self.value1 = try .init(from: decoder) + self.value2 = try .init(from: decoder) + } + public func encode(to encoder: any Encoder) throws { + try self.value1.encode(to: encoder) + try self.value2.encode(to: encoder) + } + } /// Error response object. For a full list of error codes and meanings, see: /// https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html#//apple_ref/doc/uid/TP40015240-CH4-SW1 /// diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift index ad25791e..690f194e 100644 --- a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift @@ -55,6 +55,23 @@ extension CloudKitServiceTests.Subscriptions { } """ + /// A non-duplicate `INTERNAL_ERROR` — same code as ``internalErrorJSON`` + /// but a different `reason`, so `isLikelyDuplicate` returns `false` and + /// the convenience wrapper falls through to the generic + /// `subscriptionOperationFailed` path. Lets us cover that path + /// separately from the new `subscriptionLikelyDuplicate` path. + private static let nonDuplicateInternalErrorJSON = """ + { + "subscriptions": [ + { + "subscriptionID": "1CB64DC1-2423-486C-8C9F-4E064530FBEF", + "reason": "transient backend failure", + "serverErrorCode": "INTERNAL_ERROR" + } + ] + } + """ + private static func noteCreate(_ id: String) -> SubscriptionOperation { .create(.query(subscriptionID: id, recordType: "Note", firesOn: [.create])) } @@ -79,19 +96,19 @@ extension CloudKitServiceTests.Subscriptions { Issue.record("expected a .failure result, got \(String(describing: results.first))") return } - #expect(failure.subscriptionID == Self.subID) + #expect(failure.identifier == Self.subID) #expect(failure.serverErrorCode == .internalError) #expect(failure.reason == "could not find subscription we just created") } - @Test("createSubscription() throws subscriptionOperationFailed on a failure entry") + @Test("createSubscription() throws subscriptionOperationFailed on a non-duplicate failure") internal func createThrowsOnFailure() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("CloudKitService is not available on this operating system.") return } let service = try CloudKitServiceTests.Subscriptions.makeService( - returningJSON: Self.internalErrorJSON + returningJSON: Self.nonDuplicateInternalErrorJSON ) do { @@ -106,7 +123,8 @@ extension CloudKitServiceTests.Subscriptions { return } #expect(failure.serverErrorCode == .internalError) - #expect(failure.subscriptionID == Self.subID) + #expect(failure.identifier == Self.subID) + #expect(failure.isLikelyDuplicate == false) } } @@ -141,7 +159,7 @@ extension CloudKitServiceTests.Subscriptions { Issue.record("expected results[0] to be .failure") return } - #expect(failure.subscriptionID == "bad") + #expect(failure.identifier == "bad") guard case .success(let subscription) = results[1] else { Issue.record("expected results[1] to be .success") return diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift new file mode 100644 index 00000000..a59601f7 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift @@ -0,0 +1,151 @@ +// +// CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.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.Subscriptions { + /// Tests scoped to the `INTERNAL_ERROR` / "could not find subscription + /// we just created" marker that CloudKit returns when a create collides + /// with an existing subscription whose `(recordType, firesOn)` already + /// match (verified empirically via `mistdemo + /// probe-duplicate-subscription` — uniqueness is content-based, not + /// `subscriptionID`-based). Covers `SubscriptionOperationFailure.isLikelyDuplicate`, + /// the convenience-wrapper throw of `.subscriptionLikelyDuplicate`, and + /// the batch `modifySubscriptions` path keeping the raw failure intact. + @Suite("Likely-Duplicate Cases") + internal struct LikelyDuplicateCases { + private static let database: Database = .public(.prefers(.serverToServer)) + private static let subID = "1CB64DC1-2423-486C-8C9F-4E064530FBEF" + + private static let internalErrorJSON = """ + { + "subscriptions": [ + { + "subscriptionID": "1CB64DC1-2423-486C-8C9F-4E064530FBEF", + "reason": "could not find subscription we just created", + "serverErrorCode": "INTERNAL_ERROR" + } + ] + } + """ + + @Test("createSubscription() throws subscriptionLikelyDuplicate on the duplicate marker") + internal func createThrowsSubscriptionLikelyDuplicate() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Subscriptions.makeService( + returningJSON: Self.internalErrorJSON + ) + + do { + _ = try await service.createSubscription( + .query(subscriptionID: Self.subID, recordType: "Note", firesOn: [.create]), + database: Self.database + ) + Issue.record("expected createSubscription to throw") + } catch let error as CloudKitError { + guard case .subscriptionLikelyDuplicate(let failure) = error else { + Issue.record("expected .subscriptionLikelyDuplicate, got \(error)") + return + } + #expect(failure.identifier == Self.subID) + #expect(failure.serverErrorCode == .internalError) + #expect(failure.isLikelyDuplicate == true) + } + } + + @Test("modifySubscriptions() keeps raw failure even for likely-duplicate marker") + internal func modifyStillReturnsRawFailureForDuplicate() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Subscriptions.makeService( + returningJSON: Self.internalErrorJSON + ) + + let results = try await service.modifySubscriptions( + [.create(.query(subscriptionID: Self.subID, recordType: "Note", firesOn: [.create]))], + database: Self.database + ) + + #expect(results.count == 1) + guard case .failure(let failure) = try #require(results.first) else { + Issue.record("expected a .failure result, got \(String(describing: results.first))") + return + } + #expect(failure.serverErrorCode == .internalError) + #expect(failure.isLikelyDuplicate == true) + #expect(failure.reason == "could not find subscription we just created") + } + + @Test("SubscriptionOperationFailure.isLikelyDuplicate matches only the exact marker") + internal func isLikelyDuplicateMatchesExactly() { + let exact = SubscriptionOperationFailure( + identifier: "x", + serverErrorCode: .internalError, + reason: "could not find subscription we just created" + ) + #expect(exact.isLikelyDuplicate == true) + + let wrongCode = SubscriptionOperationFailure( + identifier: "x", + serverErrorCode: .conflict, + reason: "could not find subscription we just created" + ) + #expect(wrongCode.isLikelyDuplicate == false) + + let wrongReason = SubscriptionOperationFailure( + identifier: "x", + serverErrorCode: .internalError, + reason: "transient backend failure" + ) + #expect(wrongReason.isLikelyDuplicate == false) + + let containsButNotEqual = SubscriptionOperationFailure( + identifier: "x", + serverErrorCode: .internalError, + reason: "Internal: could not find subscription we just created (post-write)" + ) + #expect(containsButNotEqual.isLikelyDuplicate == false) + + let nilReason = SubscriptionOperationFailure( + identifier: "x", + serverErrorCode: .internalError, + reason: nil + ) + #expect(nilReason.isLikelyDuplicate == false) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift index a9b003af..1cd43e49 100644 --- a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift @@ -57,7 +57,9 @@ extension CloudKitServiceTests.Subscriptions { let zone = try #require(subscriptions.first { $0.subscriptionType == .zone }) #expect(zone.zoneID == ZoneID(zoneName: "Photos", ownerName: "_defaultOwner")) - #expect(zone.firesOn == [.create, .update, .delete]) + // Zone subscriptions don't carry firesOn — only query subscriptions + // do. The wire field is ignored for `.zone` / `.database` kinds. + #expect(zone.firesOn.isEmpty) } @Test("lookupSubscriptions() returns the matching subscriptions") diff --git a/Tests/MistKitTests/Models/BatchSyncResultTests.swift b/Tests/MistKitTests/Models/BatchSyncResultTests.swift index 840c44f9..912acfd2 100644 --- a/Tests/MistKitTests/Models/BatchSyncResultTests.swift +++ b/Tests/MistKitTests/Models/BatchSyncResultTests.swift @@ -58,7 +58,7 @@ internal struct BatchSyncResultTests { name: String, code: RecordOperationFailure.ServerErrorCode = .badRequest ) -> RecordOperationFailure { - RecordOperationFailure(recordName: name, serverErrorCode: code) + RecordOperationFailure(identifier: name, serverErrorCode: code) } /// A failed per-record result. diff --git a/Tests/MistKitTests/Models/ConversionFailureTests.swift b/Tests/MistKitTests/Models/ConversionFailureTests.swift index 868fe831..c20c9285 100644 --- a/Tests/MistKitTests/Models/ConversionFailureTests.swift +++ b/Tests/MistKitTests/Models/ConversionFailureTests.swift @@ -105,7 +105,10 @@ internal struct ConversionFailureTests { @Test("RecordResult maps an error item to .failure with the server error code") internal func recordResultMapsErrorItem() throws { let item = Components.Schemas.ModifyResponse.recordsPayloadPayload.RecordOperationFailure( - .init(recordName: "rec-1", serverErrorCode: .NOT_FOUND) + .init( + value1: .init(serverErrorCode: .NOT_FOUND), + value2: .init(recordName: "rec-1") + ) ) let result = try RecordResult(from: item) @@ -113,7 +116,7 @@ internal struct ConversionFailureTests { Issue.record("Expected .failure, got \(result)") return } - #expect(error.recordName == "rec-1") + #expect(error.identifier == "rec-1") #expect(error.serverErrorCode == .notFound) } @@ -134,7 +137,7 @@ internal struct ConversionFailureTests { @Test("RecordResult.get() rethrows a failure as recordOperationFailed") internal func recordResultGetThrowsOnFailure() { let result = RecordResult.failure( - RecordOperationFailure(recordName: "rec-1", serverErrorCode: .badRequest) + RecordOperationFailure(identifier: "rec-1", serverErrorCode: .badRequest) ) #expect(throws: CloudKitError.self) { _ = try result.get() @@ -154,7 +157,7 @@ internal struct ConversionFailureTests { let updatedRecord = try successResult("existing-1") let anonymousRecord = try successResult("server-assigned") let failure = RecordResult.failure( - RecordOperationFailure(recordName: "bad-1", serverErrorCode: .notFound) + RecordOperationFailure(identifier: "bad-1", serverErrorCode: .notFound) ) let classification = OperationClassification( @@ -169,7 +172,7 @@ internal struct ConversionFailureTests { #expect(batch.created.map(\.recordName) == ["new-1"]) #expect(batch.updated.map(\.recordName) == ["existing-1"]) #expect(batch.unclassified.map(\.recordName) == ["server-assigned"]) - #expect(batch.failed.map(\.recordName) == ["bad-1"]) + #expect(batch.failed.map(\.identifier) == ["bad-1"]) // Every input result lands in exactly one bucket. #expect(batch.totalCount == 4) #expect(batch.succeededCount == 3) diff --git a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift index d71a22a1..f852d34f 100644 --- a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift +++ b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift @@ -82,8 +82,7 @@ internal struct SubscriptionConversionTests { internal func zoneRoundTrip() throws { let info = SubscriptionInfo.zone( subscriptionID: "sub-zone", - zoneID: ZoneID(zoneName: "Photos", ownerName: "_owner"), - firesOn: [.delete] + zoneID: ZoneID(zoneName: "Photos", ownerName: "_owner") ) let schema = info.schema @@ -91,12 +90,15 @@ internal struct SubscriptionConversionTests { #expect(schema.zoneID?.zoneName == "Photos") #expect(schema.zoneID?.ownerName == "_owner") #expect(schema.query == nil) + // Zone subscriptions don't carry firesOn — only `.query` does. + #expect(schema.firesOn == nil) let recovered = try SubscriptionInfo(from: schema) - #expect(recovered.subscriptionType == .zone) + let recoveredType: SubscriptionType = recovered.subscriptionType + #expect(recoveredType == .zone) #expect(recovered.zoneID == ZoneID(zoneName: "Photos", ownerName: "_owner")) #expect(recovered.query == nil) - #expect(recovered.firesOn == [.delete]) + #expect(recovered.firesOn.isEmpty) } @Test("missing subscriptionID is a conversion failure") @@ -143,12 +145,31 @@ internal struct SubscriptionConversionTests { #expect(delete.subscription?.subscriptionID == "gone") } - @Test("firesOn defaults to empty when the payload omits it") - internal func firesOnDefaultsEmpty() throws { + @Test("query subscription without firesOn is a conversion failure") + internal func queryMissingFiresOnThrows() throws { + // A `query` subscription must declare at least one fire event — an + // empty `firesOn` would never trigger, so we surface it as a + // conversion failure rather than silently decoding into a useless + // subscription. let payload = Components.Schemas.Subscription( subscriptionID: "n", subscriptionType: .query ) + expectThrow(ConversionError.subscriptionQueryMissingFiresOn) { + _ = try SubscriptionInfo(from: payload) + } + } + + @Test("zone subscription without firesOn decodes fine") + internal func zoneFiresOnNotRequired() throws { + // Zone (and database) subscriptions don't carry firesOn — only + // `.query` does. A zone payload without firesOn must still decode. + let payload = Components.Schemas.Subscription( + subscriptionID: "n", + subscriptionType: .zone, + zoneID: Components.Schemas.ZoneID(zoneName: "MyZone") + ) let recovered = try SubscriptionInfo(from: payload) #expect(recovered.firesOn.isEmpty) + #expect(recovered.zoneID?.zoneName == "MyZone") } } diff --git a/openapi.yaml b/openapi.yaml index ed6e3139..341a03a0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1235,21 +1235,105 @@ components: Subscription: type: object + description: > + A CloudKit subscription — a persistent server-side trigger that + produces push notifications when matching changes occur. + Mirrors `CloudKit.Subscription` from the CloudKit JS reference. properties: subscriptionID: type: string + description: Caller-supplied unique identifier for the subscription. subscriptionType: type: string enum: [query, zone] query: $ref: '#/components/schemas/Query' + description: > + For `subscriptionType: query`, the watched query + (`recordType` + filters + sort). zoneID: $ref: '#/components/schemas/ZoneID' + description: > + For `subscriptionType: zone`, the watched record zone. + zoneWide: + type: boolean + description: > + Zone subscriptions only. If `true`, the subscription watches + *every zone* in the database (the wire representation of a + native `CKDatabaseSubscription`); if `false`/absent, only the + zone identified by `zoneID` is watched. Only valid against + private and shared databases. Default `false`. firesOn: type: array items: type: string enum: [create, update, delete] + description: > + The record-change events that trigger a push (e.g. + `[create, update]`). CloudKit treats the exact set as the + subscription's uniqueness key — two subscriptions on the + same `(recordType, firesOn)` tuple collide regardless of + `subscriptionID`. + firesOnce: + type: boolean + description: > + If `true`, the subscription is destroyed after producing its + first notification. Default `false`. + notificationInfo: + $ref: '#/components/schemas/NotificationInfo' + description: > + How the system should alert the user when the subscription + fires. Optional. + + NotificationInfo: + type: object + description: > + How CloudKit shapes the push notification produced by a + subscription. Mirrors `CloudKit.NotificationInfo` from the + CloudKit JS reference. + properties: + alertBody: + type: string + description: The text of the alert message. + alertLocalizationKey: + type: string + description: A key to a localized alert message. + alertLocalizationArgs: + type: array + items: + type: string + description: > + Strings that appear as variables if `alertLocalizationKey` is + a format specifier. + alertActionLocalizationKey: + type: string + description: A key to the localized title of the alert's action button. + alertLaunchImage: + type: string + description: The filename of the image to use as the launch image. + soundName: + type: string + description: The filename of the sound to play when the notification arrives. + shouldBadge: + type: boolean + description: > + Whether the app icon's badge should be incremented. Default + `false`. + shouldSendContentAvailable: + type: boolean + description: > + Whether the notification should mark new content as available + (silent background fetch). Default `false`. + additionalFields: + type: array + items: + type: string + description: > + Names of record fields whose values should be included in the + notification payload. + category: + type: string + description: The notification category (UN actionable category identifier). QueryResponse: type: object @@ -1358,49 +1442,20 @@ components: $ref: '#/components/schemas/Subscription' SubscriptionOperationFailure: - type: object description: | Per-subscription error returned inline in the `subscriptions` array of a 200 modify response. Identifies the subscription that failed and why. Mirrors `RecordOperationFailure` for records. Distinct from `ErrorResponse`, which is the body of a top-level 4xx/5xx HTTP failure. - properties: - subscriptionID: - type: string - description: The identifier of the subscription the operation failed on. - serverErrorCode: - type: string - enum: - - ACCESS_DENIED - - ATOMIC_ERROR - - AUTHENTICATION_FAILED - - AUTHENTICATION_REQUIRED - - BAD_REQUEST - - CONFLICT - - EXISTS - - INTERNAL_ERROR - - NOT_FOUND - - QUOTA_EXCEEDED - - THROTTLED - - TRY_AGAIN_LATER - - VALIDATING_REFERENCE_ERROR - - ZONE_NOT_FOUND - description: The code for the error that occurred. - reason: - type: string - description: A string indicating the reason for the error. - retryAfter: - type: integer - description: Suggested seconds to wait before retrying. Absent if not retryable. - uuid: - type: string - description: A unique identifier for this error. - redirectURL: - type: string - description: Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. - required: - - subscriptionID - - serverErrorCode + allOf: + - $ref: '#/components/schemas/OperationFailureCommon' + - type: object + required: + - subscriptionID + properties: + subscriptionID: + type: string + description: The identifier of the subscription the operation failed on. SubscriptionsModifyResponse: type: object @@ -1546,35 +1601,43 @@ components: receive push notifications. Not relevant for server callers, which receive pushes via APNs proper. - RecordOperationFailure: + OperationFailureServerErrorCode: + type: string + description: | + The CloudKit server error code returned in a per-item failure entry + (record or subscription) inline in a 200 modify/lookup response. + Shared by `RecordOperationFailure` and `SubscriptionOperationFailure` + via `OperationFailureCommon`. Distinct from `ErrorResponse`'s + `serverErrorCode`, which carries a broader set of codes for top-level + 4xx/5xx HTTP failures. + enum: + - ACCESS_DENIED + - ATOMIC_ERROR + - AUTHENTICATION_FAILED + - AUTHENTICATION_REQUIRED + - BAD_REQUEST + - CONFLICT + - EXISTS + - INTERNAL_ERROR + - NOT_FOUND + - QUOTA_EXCEEDED + - THROTTLED + - TRY_AGAIN_LATER + - VALIDATING_REFERENCE_ERROR + - ZONE_NOT_FOUND + + OperationFailureCommon: type: object description: | - Per-record error returned inline in the `records` array of a 200 - modify/lookup response. Identifies the record that failed and why. - Distinct from `ErrorResponse`, which is the body of a top-level 4xx/5xx - HTTP failure. Note CloudKit does not echo `recordType` on a record error. + Shared fields of a per-item failure entry returned inline in a 200 + modify/lookup response. Composed into `RecordOperationFailure` and + `SubscriptionOperationFailure` via `allOf`; each concrete failure + type adds its own wire identifier (`recordName` / `subscriptionID`). + required: + - serverErrorCode properties: - recordName: - type: string - description: The name of the record that the operation failed on. serverErrorCode: - type: string - enum: - - ACCESS_DENIED - - ATOMIC_ERROR - - AUTHENTICATION_FAILED - - AUTHENTICATION_REQUIRED - - BAD_REQUEST - - CONFLICT - - EXISTS - - INTERNAL_ERROR - - NOT_FOUND - - QUOTA_EXCEEDED - - THROTTLED - - TRY_AGAIN_LATER - - VALIDATING_REFERENCE_ERROR - - ZONE_NOT_FOUND - description: The code for the error that occurred. + $ref: '#/components/schemas/OperationFailureServerErrorCode' reason: type: string description: A string indicating the reason for the error. @@ -1587,9 +1650,22 @@ components: redirectURL: type: string description: Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. - required: - - recordName - - serverErrorCode + + RecordOperationFailure: + description: | + Per-record error returned inline in the `records` array of a 200 + modify/lookup response. Identifies the record that failed and why. + Distinct from `ErrorResponse`, which is the body of a top-level 4xx/5xx + HTTP failure. Note CloudKit does not echo `recordType` on a record error. + allOf: + - $ref: '#/components/schemas/OperationFailureCommon' + - type: object + required: + - recordName + properties: + recordName: + type: string + description: The name of the record that the operation failed on. ErrorResponse: type: object From 9a64772c257db6e1bb7feabc7d7dac876713cf99 Mon Sep 17 00:00:00 2001 From: leogdion Date: Mon, 25 May 2026 18:52:45 -0400 Subject: [PATCH 09/14] Clean up swiftlint violations in MistKit + MistDemo [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve all swiftlint violations surfaced by the lint scripts: split CloudKitError errorDescription and SubscriptionInfo Codable into extension files to meet file_length, reorder type contents to satisfy type_contents_order, reduce cyclomatic complexity in makeTypedScalar and SubscriptionInfo init by extracting numeric/string branches and query/zone kind helpers, and drop the unused OpenAPIRuntime import. MistDemo: split AtomicBool, ProbeSubscriptionTemplate, MockBackend helpers, and the CloudKitService: WebBackend conformance into their own files; replace @unchecked Sendable with proper Sendable + lock; introduce concrete @objc shims on PushNotificationDelegate so APNs selectors land on the class (Swift forbids @objc on protocol-extension defaults); refactor ModifyCommand / ModifySubscriptionsCommand / ProbeDuplicateSubscriptionCommand to satisfy function_body_length, cyclomatic_complexity, type_contents_order, and large_tuple. Disable the discouraged_optional_boolean opt-in in both .swiftlint.yml — Bool? is used intentionally on subscription fields where nil means "let CloudKit apply its default". Co-Authored-By: Claude Opus 4.7 (1M context) --- .swiftlint.yml | 1 - Examples/MistDemo/.swiftlint.yml | 1 - Examples/MistDemo/Package.resolved | 6 +- ...cationDelegate+NSApplicationDelegate.swift | 7 +- ...licationDelegate+RemoteNotifications.swift | 10 +- .../Services/PushNotificationDelegate.swift | 89 +++++++ .../MistDemoKit/Commands/AtomicBool.swift | 48 ++++ .../Commands/CreateTokenCommand.swift | 6 + .../Commands/MistDemoLoggingBootstrap.swift | 23 +- .../MistDemoKit/Commands/ModifyCommand.swift | 66 +++--- .../Commands/ModifySubscriptionsCommand.swift | 56 +++-- ...licateSubscriptionCommand+Experiment.swift | 121 ++++++++++ .../ProbeDuplicateSubscriptionCommand.swift | 164 ++++--------- .../Commands/ProbeExperiment.swift | 24 +- .../Commands/ProbeSubscriptionTemplate.swift | 47 ++++ .../Commands/RegisterTokenCommand.swift | 6 + .../Phases/NotificationRoundtripPhase.swift | 12 +- .../Notifications/CourierNotification.swift | 18 +- .../Server/CloudKitService+WebBackend.swift | 157 ++++++++++++ .../MistDemoKit/Server/WebBackend.swift | 127 +--------- .../CourierNotificationTests.swift | 7 + .../Server/MockBackend+Helpers.swift | 88 +++++++ .../MistDemoTests/Server/MockBackend.swift | 61 +---- .../CloudKitError+ErrorDescription.swift | 223 ++++++++++++++++++ .../CloudKitService/CloudKitError.swift | 144 +---------- .../FieldValue+Components+Scalar.swift | 59 +++-- .../SubscriptionFireEvents.swift | 8 +- .../SubscriptionInfo+Codable.swift | 109 +++++++++ .../SubscriptionInfo+Schema.swift | 66 +++--- .../Subscriptions/SubscriptionInfo.swift | 82 +------ 30 files changed, 1158 insertions(+), 678 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/AtomicBool.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand+Experiment.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeSubscriptionTemplate.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Helpers.swift create mode 100644 Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift create mode 100644 Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Codable.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index e70f8bb9..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 diff --git a/Examples/MistDemo/.swiftlint.yml b/Examples/MistDemo/.swiftlint.yml index 4b110936..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 diff --git a/Examples/MistDemo/Package.resolved b/Examples/MistDemo/Package.resolved index 0a714070..7cd27b35 100644 --- a/Examples/MistDemo/Package.resolved +++ b/Examples/MistDemo/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "74809d363120c26bf126107d8453c0c07f761ce0be02bd2e5df3cc4c3b3ced84", + "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/Sources/MistDemoApp/Services/PlatformApplicationDelegate+NSApplicationDelegate.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+NSApplicationDelegate.swift index 13d97204..be53dd09 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+NSApplicationDelegate.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+NSApplicationDelegate.swift @@ -33,8 +33,13 @@ 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( - _ application: NSApplication, + _: NSApplication, didReceiveRemoteNotification userInfo: [String: Any] ) { Self.receiver?.didReceiveRemoteNotification(userInfo: userInfo) diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+RemoteNotifications.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+RemoteNotifications.swift index 3a1bec1d..6dad0c6a 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+RemoteNotifications.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+RemoteNotifications.swift @@ -33,10 +33,12 @@ extension PlatformApplicationDelegate { /// APNs delivered a device token — forward it to the registered receiver. /// - /// `public` because, when adopted by a `public` class, this satisfies the - /// matching requirement in the `public` system delegate protocol. + /// 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( - _ application: PlatformApplication, + _: PlatformApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { Self.receiver?.didRegisterForRemoteNotifications(deviceToken: deviceToken) @@ -45,7 +47,7 @@ /// APNs refused registration — forward the error to the receiver so it /// can surface in the UI. public func application( - _ application: PlatformApplication, + _: PlatformApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error ) { Self.receiver?.didFailToRegisterForRemoteNotifications(error: error) diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PushNotificationDelegate.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PushNotificationDelegate.swift index e77137f8..6ddc4ca6 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/PushNotificationDelegate.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PushNotificationDelegate.swift @@ -62,5 +62,94 @@ 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/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/CreateTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift index d84d4799..3d3cff77 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift @@ -30,6 +30,10 @@ 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 { @@ -87,3 +91,5 @@ public struct CreateTokenCommand: MistDemoCommand, OutputFormatting { return environment } } + +// swiftlint:enable indentation_width diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift index 362723a4..2d8af359 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift @@ -38,12 +38,16 @@ internal import Logging /// 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 } + guard hasBootstrapped.exchange(true) == false else { + return + } LoggingSystem.bootstrap { label in var handler = StreamLogHandler.standardOutput(label: label) handler.logLevel = @@ -51,21 +55,4 @@ internal enum MistDemoLoggingBootstrap { return handler } } - - private static let hasBootstrapped = AtomicBool() -} - -/// Minimal atomic Bool — avoids pulling in Atomics for one flag. -private final class AtomicBool: @unchecked Sendable { - private let lock = NSLock() - private var value = false - - /// Set to `newValue` and return the prior value. - 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/ModifyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift index dc8dbdd1..3d95f8f5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift @@ -67,65 +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 succeeded = results.compactMap { result in if case .success(let record) = result { record } else { nil } } let failures = results.compactMap { result in if case .failure(let error) = result { error } else { nil } } - - let rows = succeeded.map { record in - ModifyResultRow( - operation: "applied", - recordType: record.recordType, - recordName: record.recordName, - recordChangeTag: record.recordChangeTag - ) - } - - let partialFailure = !config.atomic && !failures.isEmpty - - if !failures.isEmpty { - 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)) - } - } - + Self.reportFailures(failures) let envelope = ModifyOutput( - results: rows, + results: Self.makeRows(from: succeeded), attempted: config.operations.count, succeeded: succeeded.count, - partialFailure: partialFailure + 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/ModifySubscriptionsCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift index dc5274a2..7a8ec535 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift @@ -70,6 +70,19 @@ public struct ModifySubscriptionsCommand: MistDemoCommand, OutputFormatting { 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 { @@ -79,34 +92,31 @@ public struct ModifySubscriptionsCommand: MistDemoCommand, OutputFormatting { switch config.operation { case "create": - guard let recordType = config.recordType, !recordType.isEmpty else { - throw SubscriptionCommandError.missingRecordType - } - var firesOn: SubscriptionFireEvents = [] - for raw in config.firesOn { - switch raw { - case "create": firesOn.insert(.create) - case "update": firesOn.insert(.update) - case "delete": firesOn.insert(.delete) - default: break - } - } - let created = try await service.createSubscription( - .query( - subscriptionID: subscriptionID, - recordType: recordType, - firesOn: firesOn - ), - database: config.base.database - ) - try await outputResult(created, format: config.output) - + 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/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 index 6aeb2b4d..98f30536 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand.swift @@ -30,6 +30,11 @@ 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" @@ -37,6 +42,14 @@ internal import MistKit 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. @@ -74,44 +87,7 @@ public struct ProbeDuplicateSubscriptionCommand: MistDemoCommand { self.config = config } - /// 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: [(Int, String, String)] = [] - for experiment in experiments { - let outcome = await runExperiment( - experiment, - service: service, - database: database - ) - summary.append((experiment.index, experiment.label, outcome)) - } - - print("") - print("📋 Summary") - for (index, label, outcome) in summary { - print(" #\(index) \(label)") - print(" → \(outcome)") - } - } - - private static func makeExperiments( + internal static func makeExperiments( run: Substring, recordType: String, alternateRecordType: String @@ -164,77 +140,50 @@ public struct ProbeDuplicateSubscriptionCommand: MistDemoCommand { ] } - private func runExperiment( - _ experiment: ProbeExperiment, - service: CloudKitService, - database: Database - ) async -> String { - let seedSub = experiment.seedSubscription() - let probeSub = experiment.probeSubscription() - print("") - print("▶︎ #\(experiment.index): \(experiment.label)") + /// 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( - " seed: id=\(seedSub.subscriptionID) " - + describeSubscription(seedSub) + " Each experiment: seed one subscription, probe with a variation, " + + "report seed/probe outcomes, cleanup." ) - print( - " probe: id=\(probeSub.subscriptionID) " - + describeSubscription(probeSub) + + let experiments = Self.makeExperiments( + run: probeRun, + recordType: config.recordType, + alternateRecordType: config.alternateRecordType ) - // Seed - let seedResult: SubscriptionResult? - do { - let seedResults = try await service.modifySubscriptions( - [.create(seedSub)], + var summary: [ExperimentOutcome] = [] + for experiment in experiments { + let outcome = await runExperiment( + experiment, + service: service, 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 + summary.append( + ExperimentOutcome( + index: experiment.index, + label: experiment.label, + result: outcome + ) ) - print(" probe result: \(formatResult(probeResults.first))") - probeOutcome = summarize(probeResults.first) - } catch { - print(" probe result: THREW \(error)") - probeOutcome = "threw: \(error)" } - // Cleanup — best-effort, 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)") - } + print("") + print("📋 Summary") + for row in summary { + print(" #\(row.index) \(row.label)") + print(" → \(row.result)") } - _ = seedResult - return probeOutcome } - private 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: ","))]" - } - - private func formatResult(_ result: SubscriptionResult?) -> String { + internal func formatResult(_ result: SubscriptionResult?) -> String { guard let result else { return "nil" } @@ -254,20 +203,11 @@ public struct ProbeDuplicateSubscriptionCommand: MistDemoCommand { return parts.joined(separator: " ") } } - - private 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)" - } - } } -// `ProbeExperiment` and `ProbeSubscriptionTemplate` live in -// `ProbeExperiment.swift`. +// 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 index 1efb8b4e..8affc9fa 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeExperiment.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeExperiment.swift @@ -39,14 +39,6 @@ internal struct ProbeExperiment { internal let seed: ProbeSubscriptionTemplate internal let probe: ProbeSubscriptionTemplate - internal func seedSubscription() -> SubscriptionInfo { - seed.materialize() - } - - internal func probeSubscription() -> SubscriptionInfo { - probe.materialize() - } - internal static func same( index: Int, label: String, @@ -97,18 +89,12 @@ internal struct ProbeExperiment { ) ) } -} -internal struct ProbeSubscriptionTemplate { - internal let id: String - internal let recordType: String - internal let firesOn: SubscriptionFireEvents + internal func seedSubscription() -> SubscriptionInfo { + seed.materialize() + } - internal func materialize() -> SubscriptionInfo { - .query( - subscriptionID: id, - recordType: recordType, - firesOn: firesOn - ) + 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/RegisterTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift index c2515abc..23951864 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift @@ -30,6 +30,10 @@ 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 @@ -95,3 +99,5 @@ public struct RegisterTokenCommand: MistDemoCommand { return environment } } + +// swiftlint:enable indentation_width diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/NotificationRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/NotificationRoundtripPhase.swift index 23b1d3d7..71195438 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/NotificationRoundtripPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/NotificationRoundtripPhase.swift @@ -92,11 +92,16 @@ internal struct NotificationRoundtripPhase: IntegrationPhase { ) } catch { await cleanup( - subscriptionID: subscriptionID, recordName: createdRecordName, context: context) + subscriptionID: subscriptionID, + recordName: createdRecordName, + context: context + ) throw error } - await cleanup(subscriptionID: subscriptionID, recordName: createdRecordName, context: context) + await cleanup( + subscriptionID: subscriptionID, recordName: createdRecordName, context: context + ) print("✅ Notification probe completed for subscription '\(subscriptionID)'") return NoState() #endif @@ -121,7 +126,8 @@ internal struct NotificationRoundtripPhase: IntegrationPhase { ) if context.verbose { print( - " ✅ Minted + registered courier token; polling \(token.webcourierURL.absoluteString)") + " ✅ Minted + registered courier token; polling \(token.webcourierURL.absoluteString)" + ) } return token.webcourierURL } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift b/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift index 53b405c7..532e2391 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift @@ -120,12 +120,6 @@ extension CourierNotification { fileprivate struct CloudKitPayload: Decodable { fileprivate struct Qry: Decodable { - fileprivate let sid: String? - fileprivate let rid: String? - fileprivate let zid: String? - fileprivate let firesOn: Int? - fileprivate let dbs: Int? - private enum CodingKeys: String, CodingKey { case sid case rid @@ -133,6 +127,12 @@ extension CourierNotification { case dbs case firesOn = "fo" } + + fileprivate let sid: String? + fileprivate let rid: String? + fileprivate let zid: String? + fileprivate let firesOn: Int? + fileprivate let dbs: Int? } fileprivate let nid: String? @@ -140,12 +140,12 @@ extension CourierNotification { fileprivate let qry: Qry? } - fileprivate let aps: APS? - fileprivate let cloudKit: CloudKitPayload? - private enum CodingKeys: String, CodingKey { case aps case cloudKit = "ck" } + + fileprivate let aps: APS? + fileprivate let cloudKit: CloudKitPayload? } } 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..e8a4bcc7 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift @@ -0,0 +1,157 @@ +// +// 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, + fields: [String: FieldValue], + database: MistKit.Database + ) async throws -> RecordInfo { + try await createRecord( + recordType: recordType, + 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 + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index 4e029168..1446df0d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -101,128 +101,5 @@ internal protocol WebBackend: Sendable { ) async throws } -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, - fields: [String: FieldValue], - database: MistKit.Database - ) async throws -> RecordInfo { - try await createRecord( - recordType: recordType, - 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 - ) - } -} +// The `CloudKitService: WebBackend` conformance lives in +// `CloudKitService+WebBackend.swift`. diff --git a/Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift index af94a9f3..edbccada 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift @@ -32,6 +32,11 @@ internal import Testing @testable import MistDemoKit +// Raw-string JSON fixtures below intentionally use JSON-aligned indents that +// don't match Swift source-indent steps; the rule isn't useful inside raw +// payloads. +// swiftlint:disable indentation_width + /// Decoding regression tests pinned to the three real web-courier payload /// shapes captured against a live container (#379). One record change matching /// three subscriptions produced three frames sharing a `nid` but differing by @@ -121,3 +126,5 @@ internal struct CourierNotificationTests { #expect(notification.reason == expected) } } + +// swiftlint:enable indentation_width diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Helpers.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Helpers.swift new file mode 100644 index 00000000..5de63da9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Helpers.swift @@ -0,0 +1,88 @@ +// +// MockBackend+Helpers.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 MistKit + + extension MockBackend { + internal static func stubRecord( + recordType: String, recordName: String + ) -> RecordInfo { + let json = """ + { + "recordName": "\(recordName)", + "recordType": "\(recordType)", + "recordChangeTag": null, + "fields": {}, + "created": null, + "modified": null, + "deleted": false + } + """ + // RecordInfo is Codable; round-trip through JSON keeps the stub + // independent of MistKit's internal initializer. + do { + return try JSONDecoder().decode( + RecordInfo.self, from: Data(json.utf8) + ) + } catch { + fatalError("MockBackend stubRecord JSON failed to decode: \(error)") + } + } + + /// Flatten FieldValue entries into a printable form so tests can write + /// `#expect(captured.fields["title"] == "Hi")` for strings or + /// `#expect(captured.fields["index"] == "5")` for numbers without + /// pattern-matching on FieldValue in every assertion. + /// + /// Non-primitive cases (asset, date, reference, location, list, bytes) + /// are intentionally dropped — they yield no useful String form for an + /// equality assertion. Tests that need to assert those types should + /// inspect the FieldValue directly rather than going through `flatten`. + internal static func flatten( + _ fields: [String: FieldValue] + ) -> [String: String] { + var result: [String: String] = [:] + for (name, value) in fields { + switch value { + case .string(let string): + result[name] = string + case .int64(let int): + result[name] = String(int) + case .double(let double): + result[name] = String(double) + default: + continue + } + } + return result + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index 40c5e7ae..6066f1ec 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -54,59 +54,6 @@ .query(subscriptionID: "stub-sub", recordType: "Note", firesOn: [.create]) ] - private static func stubRecord( - recordType: String, recordName: String - ) -> RecordInfo { - let json = """ - { - "recordName": "\(recordName)", - "recordType": "\(recordType)", - "recordChangeTag": null, - "fields": {}, - "created": null, - "modified": null, - "deleted": false - } - """ - // RecordInfo is Codable; round-trip through JSON keeps the stub - // independent of MistKit's internal initializer. - do { - return try JSONDecoder().decode( - RecordInfo.self, from: Data(json.utf8) - ) - } catch { - fatalError("MockBackend stubRecord JSON failed to decode: \(error)") - } - } - - /// Flatten FieldValue entries into a printable form so tests can write - /// `#expect(captured.fields["title"] == "Hi")` for strings or - /// `#expect(captured.fields["index"] == "5")` for numbers without - /// pattern-matching on FieldValue in every assertion. - /// - /// Non-primitive cases (asset, date, reference, location, list, bytes) - /// are intentionally dropped — they yield no useful String form for an - /// equality assertion. Tests that need to assert those types should - /// inspect the FieldValue directly rather than going through `flatten`. - private static func flatten( - _ fields: [String: FieldValue] - ) -> [String: String] { - var result: [String: String] = [:] - for (name, value) in fields { - switch value { - case .string(let string): - result[name] = string - case .int64(let int): - result[name] = String(int) - case .double(let double): - result[name] = String(double) - default: - continue - } - } - return result - } - internal func failNext(message: String) { pendingError = message } @@ -222,7 +169,9 @@ ) try consumePendingError() return operations.compactMap { operation in - if case .create(let info) = operation { return info } + if case .create(let info) = operation { + return info + } return nil } } @@ -238,10 +187,12 @@ database: database ) try consumePendingError() + // swiftlint:disable:next force_unwrapping + let stubURL = URL(string: "https://stub.example/webcourier")! return APNsTokenResult( environment: environment, apnsToken: "stub-apns", - webcourierURL: URL(string: "https://stub.example/webcourier")! + webcourierURL: stubURL ) } diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift new file mode 100644 index 00000000..f8a92825 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift @@ -0,0 +1,223 @@ +// +// CloudKitError+ErrorDescription.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 + +#if canImport(FoundationNetworking) + internal import FoundationNetworking +#endif + +extension CloudKitError { + /// A localized message describing what error occurred + public var errorDescription: String? { + switch self { + case .httpError(let statusCode): + return "CloudKit API error: HTTP \(statusCode)" + case .httpErrorWithDetails(let statusCode, let serverErrorCode, let reason): + return Self.httpDetailsDescription( + statusCode: statusCode, serverErrorCode: serverErrorCode, reason: reason + ) + case .httpErrorWithRawResponse(let statusCode, let rawResponse): + return "CloudKit API error: HTTP \(statusCode)\nRaw Response: \(rawResponse)" + case .invalidResponse: + return "Invalid response from CloudKit" + case .conversionFailed(let conversionError): + return "Failed to convert CloudKit response into a MistKit type: " + + (conversionError.errorDescription ?? "\(conversionError)") + case .recordOperationFailed(let recordError): + return Self.recordOperationDescription(recordError) + case .subscriptionOperationFailed(let subscriptionError): + return Self.subscriptionOperationDescription(subscriptionError) + case .subscriptionLikelyDuplicate(let subscriptionError): + return Self.subscriptionLikelyDuplicateDescription(subscriptionError) + case .underlyingError(let error): + return "CloudKit operation failed with underlying error: \(String(reflecting: error))" + case .decodingError(let error): + return Self.decodingErrorDescription(error) + case .networkError(let error): + return Self.networkErrorDescription(error) + case .unsupportedOperationType(let type): + return "Unsupported record operation type: \(type)" + case .paginationLimitExceeded(let maxPages, let records): + return + "CloudKit query exceeded pagination limit of \(maxPages) pages " + + "(collected \(records.count) records)" + case .zonePaginationLimitExceeded(let maxPages, let zones): + return + "CloudKit zone-changes exceeded pagination limit of \(maxPages) pages " + + "(collected \(zones.count) zones)" + case .missingCredentials(let database, let availability, let reason): + return Self.missingCredentialsDescription( + database: database, availability: availability, reason: reason + ) + case .invalidPrivateKey(let path, let underlying): + let location = path.map { "from '\($0)'" } ?? "from inline material" + return + "Failed to load CloudKit private key \(location): \(underlying.localizedDescription)" + case .quotaExceeded(let reason, let hint): + return Self.quotaExceededDescription(reason: reason, hint: hint) + case .badRequest(let reason): + return Self.simpleReasonDescription( + prefix: "CloudKit bad request (HTTP 400 / BAD_REQUEST)", reason: reason + ) + case .atomicFailure(let reason): + return Self.simpleReasonDescription( + prefix: "CloudKit atomic batch failure (HTTP 400 / ATOMIC_ERROR)", reason: reason + ) + } + } + + private static func httpDetailsDescription( + statusCode: Int, serverErrorCode: String?, reason: String? + ) -> String { + var message = "CloudKit API error: HTTP \(statusCode)" + if let serverErrorCode { + message += "\nServer Error Code: \(serverErrorCode)" + } + if let reason { + message += "\nReason: \(reason)" + } + return message + } + + private static func recordOperationDescription(_ recordError: RecordOperationFailure) -> String { + let identifier = recordError.identifier + let code = recordError.serverErrorCode.rawValue + var message = "CloudKit record operation failed for '\(identifier)' (\(code))" + if let reason = recordError.reason { + message += "\nReason: \(reason)" + } + return message + } + + private static func subscriptionOperationDescription( + _ subscriptionError: SubscriptionOperationFailure + ) -> String { + let identifier = subscriptionError.identifier + let code = subscriptionError.serverErrorCode.rawValue + var message = + "CloudKit subscription operation failed for '\(identifier)' (\(code))" + if let reason = subscriptionError.reason { + message += "\nReason: \(reason)" + } + return message + } + + private static func subscriptionLikelyDuplicateDescription( + _ subscriptionError: SubscriptionOperationFailure + ) -> String { + let identifier = subscriptionError.identifier + let reasonFragment: String + if let reason = subscriptionError.reason { + reasonFragment = "\"\(reason)\")." + } else { + reasonFragment = "no reason)." + } + var message = "CloudKit subscription create returned INTERNAL_ERROR for '\(identifier)'." + message += "\nLikely cause: another subscription matching this query and firesOn" + message += " already exists (CloudKit reports semantically-duplicate" + message += " subscriptions as INTERNAL_ERROR / " + message += reasonFragment + message += "\nUse listSubscriptions to find and reuse or delete the existing one." + return message + } + + private static func decodingErrorDescription(_ error: DecodingError) -> String { + var message = "Failed to decode CloudKit response" + switch error { + case .keyNotFound(let key, let context): + message += "\nMissing key: \(key.stringValue)" + message += codingPathFragment(context) + case .typeMismatch(let type, let context): + message += "\nType mismatch: expected \(type)" + message += codingPathFragment(context) + case .valueNotFound(let type, let context): + message += "\nValue not found: expected \(type)" + message += codingPathFragment(context) + case .dataCorrupted(let context): + message += "\nData corrupted" + message += codingPathFragment(context) + @unknown default: + message += "\nUnknown decoding error: \(error.localizedDescription)" + } + return message + } + + private static func codingPathFragment(_ context: DecodingError.Context) -> String { + var fragment = "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" + if let underlyingError = context.underlyingError { + fragment += "\nUnderlying error: \(underlyingError.localizedDescription)" + } + return fragment + } + + private static func networkErrorDescription(_ error: URLError) -> String { + var message = "Network error occurred" + message += "\nError code: \(error.code.rawValue)" + if let url = error.failureURLString { + message += "\nFailed URL: \(url)" + } + message += "\nDescription: \(error.localizedDescription)" + return message + } + + private static func missingCredentialsDescription( + database: Database, availability: CredentialAvailability, reason: String + ) -> String { + let availabilityLabel: String + switch availability { + case .notConfigured: + availabilityLabel = "not configured" + case .preferenceRequired: + availabilityLabel = "required by preference but not configured" + } + return + "Missing credentials for database '\(database.pathSegment)' " + + "(\(availabilityLabel)): \(reason)" + } + + private static func quotaExceededDescription(reason: String?, hint: QuotaHint?) -> String { + var message = "CloudKit quota exceeded (HTTP 413 / QUOTA_EXCEEDED)" + if let reason { + message += "\nReason: \(reason)" + } + if let hint { + message += "\nHint: \(hint.description)" + } + return message + } + + private static func simpleReasonDescription(prefix: String, reason: String?) -> String { + var message = prefix + if let reason { + message += "\nReason: \(reason)" + } + return message + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitError.swift b/Sources/MistKit/CloudKitService/CloudKitError.swift index 9f06071d..da508a5e 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError.swift @@ -28,7 +28,6 @@ // public import Foundation -internal import OpenAPIRuntime #if canImport(FoundationNetworking) internal import FoundationNetworking @@ -111,146 +110,5 @@ public enum CloudKitError: LocalizedError, Sendable { } } - /// A localized message describing what error occurred - public var errorDescription: String? { - switch self { - case .httpError(let statusCode): - return "CloudKit API error: HTTP \(statusCode)" - case .httpErrorWithDetails(let statusCode, let serverErrorCode, let reason): - var message = "CloudKit API error: HTTP \(statusCode)" - if let serverErrorCode = serverErrorCode { - message += "\nServer Error Code: \(serverErrorCode)" - } - if let reason = reason { - message += "\nReason: \(reason)" - } - return message - case .httpErrorWithRawResponse(let statusCode, let rawResponse): - return "CloudKit API error: HTTP \(statusCode)\nRaw Response: \(rawResponse)" - case .invalidResponse: - return "Invalid response from CloudKit" - case .conversionFailed(let conversionError): - return "Failed to convert CloudKit response into a MistKit type: " - + (conversionError.errorDescription ?? "\(conversionError)") - case .recordOperationFailed(let recordError): - let identifier = recordError.identifier - let code = recordError.serverErrorCode.rawValue - var message = "CloudKit record operation failed for '\(identifier)' (\(code))" - if let reason = recordError.reason { - message += "\nReason: \(reason)" - } - return message - case .subscriptionOperationFailed(let subscriptionError): - let identifier = subscriptionError.identifier - let code = subscriptionError.serverErrorCode.rawValue - var message = - "CloudKit subscription operation failed for '\(identifier)' (\(code))" - if let reason = subscriptionError.reason { - message += "\nReason: \(reason)" - } - return message - case .subscriptionLikelyDuplicate(let subscriptionError): - let identifier = subscriptionError.identifier - let reasonFragment: String - if let reason = subscriptionError.reason { - reasonFragment = "\"\(reason)\")." - } else { - reasonFragment = "no reason)." - } - var message = "CloudKit subscription create returned INTERNAL_ERROR for '\(identifier)'." - message += "\nLikely cause: another subscription matching this query and firesOn" - message += " already exists (CloudKit reports semantically-duplicate" - message += " subscriptions as INTERNAL_ERROR / " - message += reasonFragment - message += "\nUse listSubscriptions to find and reuse or delete the existing one." - return message - case .underlyingError(let error): - return "CloudKit operation failed with underlying error: \(String(reflecting: error))" - case .decodingError(let error): - var message = "Failed to decode CloudKit response" - switch error { - case .keyNotFound(let key, let context): - message += "\nMissing key: \(key.stringValue)" - message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" - if let underlyingError = context.underlyingError { - message += "\nUnderlying error: \(underlyingError.localizedDescription)" - } - case .typeMismatch(let type, let context): - message += "\nType mismatch: expected \(type)" - message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" - if let underlyingError = context.underlyingError { - message += "\nUnderlying error: \(underlyingError.localizedDescription)" - } - case .valueNotFound(let type, let context): - message += "\nValue not found: expected \(type)" - message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" - if let underlyingError = context.underlyingError { - message += "\nUnderlying error: \(underlyingError.localizedDescription)" - } - case .dataCorrupted(let context): - message += "\nData corrupted" - message += "\nCoding path: \(context.codingPath.map(\.stringValue).joined(separator: "."))" - if let underlyingError = context.underlyingError { - message += "\nUnderlying error: \(underlyingError.localizedDescription)" - } - @unknown default: - message += "\nUnknown decoding error: \(error.localizedDescription)" - } - return message - case .networkError(let error): - var message = "Network error occurred" - message += "\nError code: \(error.code.rawValue)" - if let url = error.failureURLString { - message += "\nFailed URL: \(url)" - } - message += "\nDescription: \(error.localizedDescription)" - return message - case .unsupportedOperationType(let type): - return "Unsupported record operation type: \(type)" - case .paginationLimitExceeded(let maxPages, let records): - return - "CloudKit query exceeded pagination limit of \(maxPages) pages " - + "(collected \(records.count) records)" - case .zonePaginationLimitExceeded(let maxPages, let zones): - return - "CloudKit zone-changes exceeded pagination limit of \(maxPages) pages " - + "(collected \(zones.count) zones)" - case .missingCredentials(let database, let availability, let reason): - let availabilityLabel: String - switch availability { - case .notConfigured: - availabilityLabel = "not configured" - case .preferenceRequired: - availabilityLabel = "required by preference but not configured" - } - return - "Missing credentials for database '\(database.pathSegment)' " - + "(\(availabilityLabel)): \(reason)" - case .invalidPrivateKey(let path, let underlying): - let location = path.map { "from '\($0)'" } ?? "from inline material" - return - "Failed to load CloudKit private key \(location): \(underlying.localizedDescription)" - case .quotaExceeded(let reason, let hint): - var message = "CloudKit quota exceeded (HTTP 413 / QUOTA_EXCEEDED)" - if let reason { - message += "\nReason: \(reason)" - } - if let hint { - message += "\nHint: \(hint.description)" - } - return message - case .badRequest(let reason): - var message = "CloudKit bad request (HTTP 400 / BAD_REQUEST)" - if let reason { - message += "\nReason: \(reason)" - } - return message - case .atomicFailure(let reason): - var message = "CloudKit atomic batch failure (HTTP 400 / ATOMIC_ERROR)" - if let reason { - message += "\nReason: \(reason)" - } - return message - } - } + // `errorDescription` lives in `CloudKitError+ErrorDescription.swift`. } diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift index ad02e46f..f4957791 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift @@ -64,30 +64,57 @@ extension FieldValue { type fieldType: Components.Schemas.FieldValueResponse._typePayload?, fieldName: String ) throws(ConversionError) -> FieldValue? { - guard let fieldType else { return nil } + guard let fieldType else { + return nil + } + switch fieldType { + case .TIMESTAMP, .DOUBLE, .INT64: + return try makeTypedNumericScalar(from: value, type: fieldType, fieldName: fieldName) + case .BYTES, .STRING: + return try makeTypedStringScalar(from: value, type: fieldType, fieldName: fieldName) + default: + return nil + } + } + + /// Numeric branch of ``makeTypedScalar(from:type:fieldName:)`` — validates the value + /// is numeric, then returns a domain value for `TIMESTAMP`/`DOUBLE` or nil for `INT64` + /// (which defers to inference to avoid truncating a fractional number). + private static func makeTypedNumericScalar( + from value: Components.Schemas.FieldValueResponse.valuePayload, + type fieldType: Components.Schemas.FieldValueResponse._typePayload, + fieldName: String + ) throws(ConversionError) -> FieldValue? { + let number = try requireNumeric( + value, fieldName: fieldName, declaredType: fieldType.rawValue + ) switch fieldType { case .TIMESTAMP: - let millis = try requireNumeric(value, fieldName: fieldName, declaredType: fieldType.rawValue) - return .date(Date(timeIntervalSince1970: millis / 1_000)) + return .date(Date(timeIntervalSince1970: number / 1_000)) case .DOUBLE: - return .double( - try requireNumeric(value, fieldName: fieldName, declaredType: fieldType.rawValue) - ) - case .INT64: - _ = try requireNumeric(value, fieldName: fieldName, declaredType: fieldType.rawValue) - return nil - case .BYTES: - return .bytes( - try requireString(value, fieldName: fieldName, declaredType: fieldType.rawValue) - ) - case .STRING: - _ = try requireString(value, fieldName: fieldName, declaredType: fieldType.rawValue) - return nil + return .double(number) default: return nil } } + /// String branch of ``makeTypedScalar(from:type:fieldName:)`` — validates the value + /// is a string, then returns a `.bytes` domain value for `BYTES` or nil for `STRING` + /// (which defers to inference, already producing `.string`). + private static func makeTypedStringScalar( + from value: Components.Schemas.FieldValueResponse.valuePayload, + type fieldType: Components.Schemas.FieldValueResponse._typePayload, + fieldName: String + ) throws(ConversionError) -> FieldValue? { + let string = try requireString( + value, fieldName: fieldName, declaredType: fieldType.rawValue + ) + if case .BYTES = fieldType { + return .bytes(string) + } + return nil + } + /// Require that `value` carries a number, throwing ``ConversionError/typeValueMismatch`` /// when a numeric `type` is declared over a non-numeric value. private static func requireNumeric( diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift index ee0bb8c1..61234616 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift @@ -41,9 +41,7 @@ internal import MistKitOpenAPI /// `subscriptionID` (see GH brightdigit/MistKit#387), and the wire /// format is JSON `["create","update","delete"]`. public struct SubscriptionFireEvents: OptionSet, Sendable, Hashable { - // MARK: - Public - - public let rawValue: Int + // MARK: - Type Properties /// Fire when a matching record is created. public static let create = SubscriptionFireEvents(rawValue: 1 << 0) @@ -55,6 +53,10 @@ public struct SubscriptionFireEvents: OptionSet, Sendable, Hashable { /// All three record-change events. public static let all: SubscriptionFireEvents = [.create, .update, .delete] + // MARK: - Instance Properties + + public let rawValue: Int + // MARK: - Lifecycle public init(rawValue: Int) { diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Codable.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Codable.swift new file mode 100644 index 00000000..db0b8ce0 --- /dev/null +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Codable.swift @@ -0,0 +1,109 @@ +// +// SubscriptionInfo+Codable.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. +// + +extension SubscriptionInfo: Codable { + private enum CodingKeys: String, CodingKey { + case subscriptionID + case subscriptionType + case query + case zoneID + case zoneWide + case firesOn + case firesOnce + case notificationInfo + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.subscriptionID = try container.decode(String.self, forKey: .subscriptionID) + self.notificationInfo = + try container.decodeIfPresent(NotificationInfo.self, forKey: .notificationInfo) + + let type = try container.decode(SubscriptionType.self, forKey: .subscriptionType) + switch type { + case .query: + self.kind = try Self.decodeQueryKind(from: container, codingPath: decoder.codingPath) + case .zone: + self.kind = try Self.decodeZoneOrDatabaseKind(from: container) + } + } + + private static func decodeQueryKind( + from container: KeyedDecodingContainer, + codingPath: [any CodingKey] + ) throws -> Kind { + let query = try container.decode(Query.self, forKey: .query) + let firesOn = + try container.decodeIfPresent(SubscriptionFireEvents.self, forKey: .firesOn) ?? [] + guard !firesOn.isEmpty else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath + [CodingKeys.firesOn], + debugDescription: + "Query subscription missing or empty firesOn — must declare " + + "at least one of [create, update, delete]." + ) + ) + } + let firesOnce = try container.decodeIfPresent(Bool.self, forKey: .firesOnce) + return .query(query, firesOn: firesOn, firesOnce: firesOnce) + } + + // CloudKit collapses both "watch one zone" and "watch the whole + // database" into `subscriptionType: zone`; `zoneWide: true` is the + // database-subscription marker. Reading it as `.database` here + // preserves the native CKSubscription trichotomy on the domain side. + private static func decodeZoneOrDatabaseKind( + from container: KeyedDecodingContainer + ) throws -> Kind { + let wide = try container.decodeIfPresent(Bool.self, forKey: .zoneWide) ?? false + if wide { + return .database + } + let zoneID = try container.decode(ZoneID.self, forKey: .zoneID) + return .zone(zoneID) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(subscriptionID, forKey: .subscriptionID) + try container.encode(subscriptionType, forKey: .subscriptionType) + try container.encodeIfPresent(notificationInfo, forKey: .notificationInfo) + switch kind { + case .query(let query, let firesOn, let firesOnce): + try container.encode(query, forKey: .query) + try container.encode(firesOn, forKey: .firesOn) + try container.encodeIfPresent(firesOnce, forKey: .firesOnce) + case .zone(let zoneID): + try container.encode(zoneID, forKey: .zoneID) + case .database: + try container.encode(true, forKey: .zoneWide) + } + } +} diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift index 4c0e61ed..08481608 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift @@ -92,34 +92,9 @@ extension SubscriptionInfo { let kind: Kind switch SubscriptionType(from: subscriptionType) { case .query: - // Preserve prior behavior: a `query` subscription payload that - // omits the query body still decodes (callers can inspect the - // empty `Query` and decide whether to treat it as invalid). - let query = subscription.query.map(Query.init) ?? Query(Components.Schemas.Query()) - let firesOn = SubscriptionFireEvents(schemaValues: subscription.firesOn ?? []) - guard !firesOn.isEmpty else { - try ConversionError.subscriptionQueryMissingFiresOn.reportAndThrow() - } - kind = .query(query, firesOn: firesOn, firesOnce: subscription.firesOnce) + kind = try Self.makeQueryKind(from: subscription) case .zone: - // CloudKit collapses zone- and database-scoped subscriptions into - // `subscriptionType: zone`; `zoneWide: true` is the - // database-subscription marker. Surface them as separate `Kind` - // cases so callers don't have to know about the wire collapse. - if subscription.zoneWide == true { - kind = .database - } else { - guard let payload = subscription.zoneID else { - // A non-wide zone subscription without a zoneID is invalid; - // surface as missing-zone-name failure for parity with - // existing behavior. - try ConversionError.zoneMissingName.reportAndThrow() - } - guard let zoneName = payload.zoneName else { - try ConversionError.zoneMissingName.reportAndThrow() - } - kind = .zone(ZoneID(zoneName: zoneName, ownerName: payload.ownerName)) - } + kind = try Self.makeZoneOrDatabaseKind(from: subscription) } self.init( @@ -128,4 +103,41 @@ extension SubscriptionInfo { notificationInfo: subscription.notificationInfo.map(NotificationInfo.init(from:)) ) } + + /// Build the `.query` ``Kind`` from a `Subscription` payload, throwing when + /// `firesOn` is empty (a query subscription with no triggers is invalid). + /// Preserves prior behavior: a missing `query` body still decodes — callers + /// inspect the empty `Query` and decide whether to treat it as invalid. + private static func makeQueryKind( + from subscription: Components.Schemas.Subscription + ) throws(ConversionError) -> Kind { + let query = subscription.query.map(Query.init) ?? Query(Components.Schemas.Query()) + let firesOn = SubscriptionFireEvents(schemaValues: subscription.firesOn ?? []) + guard !firesOn.isEmpty else { + try ConversionError.subscriptionQueryMissingFiresOn.reportAndThrow() + } + return .query(query, firesOn: firesOn, firesOnce: subscription.firesOnce) + } + + /// Build a `.zone`-or-`.database` ``Kind`` from a `Subscription` payload. + /// + /// CloudKit collapses zone- and database-scoped subscriptions into + /// `subscriptionType: zone`; `zoneWide: true` is the database-subscription + /// marker. This surfaces them as separate `Kind` cases so callers don't + /// have to know about the wire collapse. A non-wide zone subscription + /// without a zoneID/zoneName throws ``ConversionError/zoneMissingName``. + private static func makeZoneOrDatabaseKind( + from subscription: Components.Schemas.Subscription + ) throws(ConversionError) -> Kind { + if subscription.zoneWide == true { + return .database + } + guard let payload = subscription.zoneID else { + try ConversionError.zoneMissingName.reportAndThrow() + } + guard let zoneName = payload.zoneName else { + try ConversionError.zoneMissingName.reportAndThrow() + } + return .zone(ZoneID(zoneName: zoneName, ownerName: payload.ownerName)) + } } diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift index 63531994..05776c40 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo.swift @@ -41,10 +41,6 @@ /// ``database(subscriptionID:notificationInfo:)`` to build one; /// CloudKit Web Services requires the caller to supply the /// `subscriptionID`. -// `Bool?` is used intentionally — `nil` means "don't set the field; -// let CloudKit apply its server-side default" (e.g. `firesOnce`). -// swiftlint:disable discouraged_optional_boolean - public struct SubscriptionInfo: Sendable { /// The variant-specific data for a subscription. Mirrors the three /// native CloudKit subscription classes: @@ -68,6 +64,9 @@ public struct SubscriptionInfo: Sendable { /// public factory and ``SubscriptionInfo/init(subscriptionID:kind:notificationInfo:)`` /// `precondition`-trap empty sets, and the schema/Codable decoders /// throw on the same. + /// + /// `firesOnce` is `Bool?` intentionally — `nil` means "don't set; + /// let CloudKit apply its server-side default". case query(Query, firesOn: SubscriptionFireEvents, firesOnce: Bool? = nil) /// A zone subscription that fires when any record in the named @@ -223,78 +222,3 @@ extension SubscriptionInfo { return nil } } - -// MARK: - Codable (wire-compatible flat encoding) - -extension SubscriptionInfo: Codable { - private enum CodingKeys: String, CodingKey { - case subscriptionID - case subscriptionType - case query - case zoneID - case zoneWide - case firesOn - case firesOnce - case notificationInfo - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.subscriptionID = try container.decode(String.self, forKey: .subscriptionID) - self.notificationInfo = - try container.decodeIfPresent(NotificationInfo.self, forKey: .notificationInfo) - - let type = try container.decode(SubscriptionType.self, forKey: .subscriptionType) - switch type { - case .query: - let query = try container.decode(Query.self, forKey: .query) - let firesOn = - try container.decodeIfPresent(SubscriptionFireEvents.self, forKey: .firesOn) ?? [] - guard !firesOn.isEmpty else { - throw DecodingError.dataCorrupted( - .init( - codingPath: decoder.codingPath + [CodingKeys.firesOn], - debugDescription: - "Query subscription missing or empty firesOn — must declare " - + "at least one of [create, update, delete]." - ) - ) - } - let firesOnce = try container.decodeIfPresent(Bool.self, forKey: .firesOnce) - self.kind = .query(query, firesOn: firesOn, firesOnce: firesOnce) - case .zone: - // CloudKit collapses both "watch one zone" and "watch the whole - // database" into `subscriptionType: zone`; `zoneWide: true` is - // the database-subscription marker. Read it as `.database` so the - // domain side preserves the native CKSubscription trichotomy. - let wide = try container.decodeIfPresent(Bool.self, forKey: .zoneWide) ?? false - if wide { - self.kind = .database - } else { - let zoneID = try container.decode(ZoneID.self, forKey: .zoneID) - self.kind = .zone(zoneID) - } - } - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(subscriptionID, forKey: .subscriptionID) - try container.encode(subscriptionType, forKey: .subscriptionType) - try container.encodeIfPresent(notificationInfo, forKey: .notificationInfo) - switch kind { - case .query(let query, let firesOn, let firesOnce): - try container.encode(query, forKey: .query) - try container.encode(firesOn, forKey: .firesOn) - try container.encodeIfPresent(firesOnce, forKey: .firesOnce) - case .zone(let zoneID): - try container.encode(zoneID, forKey: .zoneID) - case .database: - try container.encode(true, forKey: .zoneWide) - } - } -} - -// swiftlint:enable discouraged_optional_boolean - -// The OpenAPI schema bridge lives in `SubscriptionInfo+Schema.swift`. From 9806678887205b6f992f4fb94ddc4c80be9a1e61 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 25 May 2026 19:24:24 -0400 Subject: [PATCH 10/14] Address PR #381 review: move CourierNotification to MistKit, lint cleanup (#379) - Move CourierNotification decoder into MistKit as public API (Models/Notifications/); it models generic CloudKit push payloads. Keep WebCourierPoller/CourierFrame as MistDemo glue; relocate decoder tests to MistKitTests. - Make WebCourierPoller's transport injectable (Sendable closure) for testing while keeping the dedicated ephemeral URLSession default; document the 421 Misdirected Request hazard that rules out reusing the CloudKit ClientTransport. - Delete WEB_COURIER_SPIKE.md and its dangling doc reference. - Add doc comments for 8 swift-format AllPublicDeclarationsHaveDocumentation warnings (Query, SubscriptionFireEvents, SubscriptionInfo). - Replace MockBackend force-unwrap with guard/throw (NeverForceUnwrap); the prior swiftlint:disable didn't apply to swift-format. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Notifications/CourierFrame.swift | 5 +- .../Notifications/WebCourierPoller.swift | 46 ++++- .../MistDemoTests/Server/MockBackend.swift | 6 +- Examples/MistDemo/WEB_COURIER_SPIKE.md | 182 ------------------ .../Notifications/CourierNotification.swift | 29 +-- Sources/MistKit/Models/Queries/Query.swift | 2 + .../SubscriptionFireEvents.swift | 4 + .../SubscriptionInfo+Codable.swift | 2 + .../CourierNotificationTests.swift | 4 +- 9 files changed, 71 insertions(+), 209 deletions(-) delete mode 100644 Examples/MistDemo/WEB_COURIER_SPIKE.md rename {Examples/MistDemo/Sources/MistDemoKit => Sources/MistKit/Models}/Notifications/CourierNotification.swift (89%) rename {Examples/MistDemo/Tests/MistDemoTests => Tests/MistKitTests/Models}/Notifications/CourierNotificationTests.swift (99%) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift b/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift index 2c35693e..a37c37ff 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift @@ -29,13 +29,14 @@ #if !os(WASI) internal import Foundation + internal import MistKit /// A single raw response from a CloudKit web-courier long-poll. /// /// The web-courier wire format is **not** documented in Apple's CloudKit Web /// Services REST reference — CloudKit JS consumes it internally — so this - /// frame preserves the unparsed bytes alongside a typed decode. See #379 / - /// `WEB_COURIER_SPIKE.md` for the verified payload shape. + /// frame preserves the unparsed bytes alongside a typed decode. See #379 + /// for the verified payload shape. internal struct CourierFrame: Sendable { /// HTTP status returned by the courier endpoint, when available. internal let statusCode: Int? diff --git a/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift b/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift index 7943d5e9..97bdf9d0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift @@ -29,6 +29,7 @@ #if !os(WASI) internal import Foundation + internal import MistKit #if canImport(FoundationNetworking) internal import FoundationNetworking @@ -38,23 +39,41 @@ /// notifications without a device or APNs entitlement — the only fully /// headless way to observe a push end-to-end. /// - /// - Important: This uses a **dedicated** ephemeral `URLSession`, never the - /// CloudKit API `ClientTransport`. The courier host is distinct from - /// `api.apple-cloudkit.com`; sharing an HTTP/2 connection across the two - /// risks 421 Misdirected Request, the same hazard called out for asset - /// uploads in CLAUDE.md. + /// - Important: The transport defaults to a **dedicated** ephemeral + /// `URLSession`, and must **never** be the CloudKit API `ClientTransport`. + /// The courier host is distinct from `api.apple-cloudkit.com`; reusing the + /// CloudKit transport's HTTP/2 connection pool across the two hosts risks + /// **421 Misdirected Request** — the same hazard that makes MistKit upload + /// assets through a separate `AssetUploader` closure rather than the shared + /// transport (see CLAUDE.md, "Asset Upload Transport Design"). The + /// transport is injectable only so tests can drive the poller without a + /// live courier, not to share it with the CloudKit client. internal struct WebCourierPoller { + /// A single courier long-poll round-trip: issue the request and return the + /// body plus response. Defaults to a dedicated ephemeral `URLSession`; + /// inject only to stub the courier in tests. See the type's 421 note — + /// never back this with the CloudKit `ClientTransport`. + internal typealias Transport = @Sendable (URLRequest) async throws -> (Data, URLResponse) + private let courierURL: URL private let perPollTimeout: TimeInterval + private let transport: Transport? private let session: URLSession /// - Parameters: /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. /// - perPollTimeout: How long a single long-poll request waits before /// the server (or this client) gives up and the caller polls again. - internal init(courierURL: URL, perPollTimeout: TimeInterval = 30) { + /// - transport: Optional injected round-trip used in place of the + /// dedicated `URLSession`. Leave `nil` in production. + internal init( + courierURL: URL, + perPollTimeout: TimeInterval = 30, + transport: Transport? = nil + ) { self.courierURL = courierURL self.perPollTimeout = perPollTimeout + self.transport = transport let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = perPollTimeout + 5 configuration.waitsForConnectivity = false @@ -68,7 +87,13 @@ var request = URLRequest(url: courierURL) request.httpMethod = "GET" request.timeoutInterval = perPollTimeout - let (data, response) = try await session.data(for: request) + let data: Data + let response: URLResponse + if let transport { + (data, response) = try await transport(request) + } else { + (data, response) = try await session.data(for: request) + } let statusCode = (response as? HTTPURLResponse)?.statusCode return CourierFrame(statusCode: statusCode, raw: data) } @@ -104,9 +129,14 @@ // non-Sendable URLSession never crosses the concurrency boundary. let courierURL = self.courierURL let perPollTimeout = self.perPollTimeout + let transport = self.transport return AsyncThrowingStream { continuation in let task = Task { - let poller = WebCourierPoller(courierURL: courierURL, perPollTimeout: perPollTimeout) + let poller = WebCourierPoller( + courierURL: courierURL, + perPollTimeout: perPollTimeout, + transport: transport + ) do { while !Task.isCancelled { let frame = try await poller.pollOnce() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index 6066f1ec..aff87595 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -187,8 +187,10 @@ database: database ) try consumePendingError() - // swiftlint:disable:next force_unwrapping - let stubURL = URL(string: "https://stub.example/webcourier")! + guard let stubURL = URL(string: "https://stub.example/webcourier") else { + struct InvalidStubURL: Error {} + throw InvalidStubURL() + } return APNsTokenResult( environment: environment, apnsToken: "stub-apns", diff --git a/Examples/MistDemo/WEB_COURIER_SPIKE.md b/Examples/MistDemo/WEB_COURIER_SPIKE.md deleted file mode 100644 index f0d64b61..00000000 --- a/Examples/MistDemo/WEB_COURIER_SPIKE.md +++ /dev/null @@ -1,182 +0,0 @@ -# Web-Courier Wire-Format Spike (#379) - -## Goal - -Capture the **CloudKit web-courier long-poll protocol** by observing CloudKit -JS do it in a browser, so we can replicate it in: - -- `WebCourierPoller` (Swift) — the headless integration-test receiver, and -- a `fetch` loop in `tokens.js` — MistKit-mode reception parity in the web app. - -Apple documents the *parsed* `CloudKit.Notification` object but **not** the -courier transport. This spike fills that gap. Three unknowns to resolve: - -1. **Request shape** — the exact URL + query params + headers CloudKit JS sends - on each courier `GET`. -2. **Response framing** — the body shape when a notification is delivered vs. - an empty keepalive/timeout, and the HTTP status in each case. -3. **The cursor** — how the *next* poll differs from the first so the courier - doesn't redeliver (a token/marker in the URL, a header, or the body). - -> Why observe CloudKit JS instead of blind-polling: the web app already wires -> `registerForNotifications()` + `addNotificationListener` in CloudKit JS mode -> (`Sources/MistDemoKit/Resources/js/tokens.js:42-55`). Watching it on the wire -> shows the real request params and cursor handling — far more reliable than -> guessing. - -## Findings so far - -**Unknown #1 (request shape) — SOLVED.** `tokens/create` returns a -self-contained courier URL; you `GET` it verbatim, no extra params/headers: - -``` -https://webcourier.sandbox.push.apple.com:443/aps?tok=&ttl=43200 -``` - -- Host is **APNs' web-courier** (Safari-push long-poll infra), *not* a CloudKit - host. `.sandbox.` = development; production is `webcourier.push.apple.com`. -- `tok` = the `apnsToken` verbatim; `ttl=43200` = token valid 12h (a long-lived - poller must re-mint before expiry). -- Confirms `WebCourierPoller.pollOnce()`'s design (GET the URL as-is). - -> ⚠️ The `apnsToken` and the browser's web-auth session cookie are **live -> secrets** — never paste the literal values into this file, commits, or issues. - -**Unknown #2 (framing) — SOLVED.** HTTP 200 with a single JSON object per poll: - -```jsonc -{ "aps": { "alert": "…" }, // OPTIONAL — only when the subscription set an alert - "ck": { "nid": "…", "cid": "…", // notificationID, containerIdentifier - "ckuserid": "…", "ce": 2, // caller user id; "ce" = protocol/env code (unconfirmed) - "qry": { "sid": "…", // subscriptionID - "rid": "…", // recordName - "fo": 1, // fires-on: 1=created 2=updated 3=deleted - "zid": "…", "dbs": 1, "zoid": "…" } } } -``` - -**Unknown #3 (cursor) — SOLVED: there is no cursor.** The courier is a -**consume-on-delivery FIFO queue**. Each `GET` pops exactly **one** queued -notification; when the queue is empty the request long-polls (hangs) until a new -push arrives. No ack/marker/`since` param is involved — re-`GET`ting the same URL -just drains the next item. - -> ⚠️ `nid` is **not** unique per delivery. One change matching N subscriptions -> enqueues N notifications that **share a `nid`** but differ by `sid` (verified: -> two back-to-back polls returned the same `nid`/`rid` with different `sid`s). -> So consumers must **not** de-dup on `nid` — `WebCourierPoller.notifications()` -> therefore does no de-duplication. - -> NOTE: the courier `GET` only appears in **CloudKit JS mode** (in MistKit mode -> the browser doesn't poll it — that's the reception gap). Use CloudKit JS mode -> for the DevTools route, or the direct-`curl` route which sidesteps the browser. - -### Fast path: long-poll the courier directly - -Precondition: a `Note` subscription exists (create/update/delete) **and** this -token is registered (the MistKit-mode tokens panel does create+register). - -```bash -# Terminal 1 — hangs until a push arrives, then prints the frame: -curl -N 'https://webcourier.sandbox.push.apple.com:443/aps?tok=&ttl=43200' - -# Terminal 2 — fire the subscription: -swift run mistdemo create # defaults to a Note -``` - -Re-run Terminal 1 after a delivery to see whether the next poll differs (cursor). -Expect an APNs payload: an `aps` dict + a `ck` dict carrying the CloudKit -notification (`subscriptionID`, record name, fire reason). - -## Prerequisites - -- CloudKit credentials in `Examples/MistDemo/.env` (see `CLAUDE.md` → MistDemo - Configuration). For the web app you need at minimum: - ``` - CLOUDKIT_CONTAINER_ID=iCloud.com.yourorg.yourapp - CLOUDKIT_ENVIRONMENT=development - CLOUDKIT_API_TOKEN=… - CLOUDKIT_WEB_AUTH_TOKEN=… - ``` -- A record type to subscribe to (the demo uses `Note`). -- A browser with DevTools (Chrome/Safari/Firefox all fine). - -## Procedure - -1. **Start the web app** from `Examples/MistDemo`: - ```bash - swift run mistdemo web # serves http://localhost:8080 by default - ``` - Open the page and **switch the mode toggle to "CloudKit JS"** (this routes - the panels through Apple's SDK in the browser, not through `/api/*`). - -2. **Authenticate** via the auth panel (CloudKit JS `setUpAuth` / sign-in). - -3. **Create a query subscription** in the subscriptions panel: - - record type: `Note` - - fires on: **create, update, delete** — i.e. *any* change to a `Note`. - - note the `subscriptionID` it returns. - - For the capture itself, a **create** is the simplest single action to fire - it (step 6); once the wire format is confirmed, the same subscription also - delivers on updates and deletes. - -4. **Open DevTools → Network tab.** Then: - - Enable **"Preserve log"** (long-polls reopen repeatedly; without this the - entries get cleared and you lose the cursor progression). - - Filter to the CloudKit host (type `cloudkit` or `icloud` in the filter). - -5. **Click "Register for notifications"** in the tokens panel. In the Network - tab you should now see, in order: - - a `POST …/tokens/create` (returns `apnsToken` + `webcourierURL`), and - - the **first courier `GET`** against that `webcourierURL`, left *pending* - (this is the long-poll holding open). - -6. **Trigger a notification.** In a second browser tab, or from the CLI: - ```bash - swift run mistdemo create # creates a Note → fires the subscription - ``` - Within a few seconds the pending courier `GET` should **resolve with a body**, - and CloudKit JS should immediately open the **next** courier `GET`. - -7. **Capture three requests** from the Network tab (right-click → "Copy as - cURL", or export the whole session as **HAR**): - - the `tokens/create` POST (request body + response), - - the **first** courier `GET` (the empty/keepalive one, if any), and - - the courier `GET` that **delivered** the notification, plus the **next** - `GET` after it (to see the cursor advance). - -## What to record (paste into issue #379) - -For each courier `GET`: - -``` -URL (full, incl. query params): ______________________________________________ -Method / headers of interest: ______________________________________________ -HTTP status: ______________________________________________ -Response body (verbatim): ______________________________________________ -``` - -Then answer the three unknowns: - -- [ ] **Request shape** — what query params/headers identify the token? Is the - `apnsToken` in the URL, a header, or implicit via cookie/session? -- [ ] **Response framing** — JSON object? array of notifications? What does an - *empty* poll (timeout/keepalive) return — empty body? `204`? `{}`? -- [ ] **Cursor** — what changes between the delivering `GET` and the next one? - (a `?…token=` / `?…ck=` param, a sequence number in the prior body, etc.) -- [ ] **Notification body → documented fields** — confirm the wire body maps to - `subscriptionID`, `notificationType`, `queryNotificationReason`, - `recordName`, `zoneID` (the `CloudKit.Notification` fields). - -## How findings feed the code - -| Finding | Lands in | -|---|---| -| Request URL/params/headers | `WebCourierPoller.pollOnce()` request construction | -| Response framing + empty-poll detection | `CourierFrame` parsing + `waitForFrame()` empty check | -| Cursor handling | new `nextURL`/`cursor` plumbing in `WebCourierPoller` loop | -| Notification body shape | a `Decodable` `CourierNotification` model (mirrors `CloudKit.Notification`) | -| Confirmation it's browser-reachable | greenlights the `tokens.js` MistKit-mode `fetch` loop (no server proxy) | - -Once the cursor + framing are known, `WebCourierPoller` can graduate from a raw -frame probe into a real notification stream, and the same parsing drops into the -web app's JS. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift b/Sources/MistKit/Models/Notifications/CourierNotification.swift similarity index 89% rename from Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift rename to Sources/MistKit/Models/Notifications/CourierNotification.swift index 532e2391..510e3fbc 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierNotification.swift +++ b/Sources/MistKit/Models/Notifications/CourierNotification.swift @@ -1,6 +1,6 @@ // // CourierNotification.swift -// MistDemo +// MistKit // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import Foundation +public import Foundation /// A CloudKit notification decoded from a web-courier frame. /// @@ -43,33 +43,36 @@ internal import Foundation /// Only query-notification fields are mapped today (the demo creates query /// subscriptions). Zone notifications would surface a sibling `ck` block; add /// them here when needed. -internal struct CourierNotification: Sendable { +public struct CourierNotification: Sendable { /// The event that fired the subscription — the `ck.qry.fo` code. - internal enum Reason: Int, Sendable { + public enum Reason: Int, Sendable { + /// A matching record was created (`fo` == 1). case recordCreated = 1 + /// A matching record was updated (`fo` == 2). case recordUpdated = 2 + /// A matching record was deleted (`fo` == 3). case recordDeleted = 3 } /// `ck.nid` — unique id for this notification. - internal let notificationID: String? + public let notificationID: String? /// `ck.cid` — the container that generated the notification. - internal let containerIdentifier: String? + public let containerIdentifier: String? /// `ck.qry.sid` — the subscription that fired. - internal let subscriptionID: String? + public let subscriptionID: String? /// `ck.qry.rid` — the record that changed. - internal let recordName: String? + public let recordName: String? /// `ck.qry.zid` — the zone the record lives in. - internal let zoneID: String? + public let zoneID: String? /// `ck.qry.fo` — why the subscription fired. - internal let reason: Reason? + public let reason: Reason? /// `ck.qry.dbs` — database-scope code (kept raw; interpretation unconfirmed). - internal let databaseScope: Int? + public let databaseScope: Int? /// `aps.alert` — the alert text, when present. - internal let alertBody: String? + public let alertBody: String? /// Decode a notification from a raw courier frame body. - internal init(data: Data) throws { + public init(data: Data) throws { let wire = try JSONDecoder().decode(Wire.self, from: data) let cloudKit = wire.cloudKit let query = cloudKit?.qry diff --git a/Sources/MistKit/Models/Queries/Query.swift b/Sources/MistKit/Models/Queries/Query.swift index 01d7fbab..312b3003 100644 --- a/Sources/MistKit/Models/Queries/Query.swift +++ b/Sources/MistKit/Models/Queries/Query.swift @@ -72,10 +72,12 @@ public struct Query: Codable, Sendable { ) } + /// Decodes a query from the CloudKit wire format. public init(from decoder: any Decoder) throws { self.schema = try Components.Schemas.Query(from: decoder) } + /// Encodes the query to the CloudKit wire format. public func encode(to encoder: any Encoder) throws { try self.schema.encode(to: encoder) } diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift index 61234616..379ab2aa 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionFireEvents.swift @@ -55,10 +55,12 @@ public struct SubscriptionFireEvents: OptionSet, Sendable, Hashable { // MARK: - Instance Properties + /// The underlying bitmask backing the option set. public let rawValue: Int // MARK: - Lifecycle + /// Creates an event set from a raw bitmask value. public init(rawValue: Int) { self.rawValue = rawValue } @@ -71,6 +73,7 @@ extension SubscriptionFireEvents: Codable { private static let updateWire = "update" private static let deleteWire = "delete" + /// Decodes the event set from the CloudKit wire format (an array of strings). public init(from decoder: any Decoder) throws { let strings = try [String](from: decoder) var events: SubscriptionFireEvents = [] @@ -91,6 +94,7 @@ extension SubscriptionFireEvents: Codable { self = events } + /// Encodes the event set to the CloudKit wire format (an array of strings). public func encode(to encoder: any Encoder) throws { var strings: [String] = [] if contains(.create) { strings.append(Self.createWire) } diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Codable.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Codable.swift index db0b8ce0..fab64795 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Codable.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Codable.swift @@ -39,6 +39,7 @@ extension SubscriptionInfo: Codable { case notificationInfo } + /// Decodes a subscription from the CloudKit wire format. public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.subscriptionID = try container.decode(String.self, forKey: .subscriptionID) @@ -90,6 +91,7 @@ extension SubscriptionInfo: Codable { return .zone(zoneID) } + /// Encodes the subscription to the CloudKit wire format. public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(subscriptionID, forKey: .subscriptionID) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift b/Tests/MistKitTests/Models/Notifications/CourierNotificationTests.swift similarity index 99% rename from Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift rename to Tests/MistKitTests/Models/Notifications/CourierNotificationTests.swift index edbccada..08447f0c 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Notifications/CourierNotificationTests.swift +++ b/Tests/MistKitTests/Models/Notifications/CourierNotificationTests.swift @@ -1,6 +1,6 @@ // // CourierNotificationTests.swift -// MistDemo +// MistKit // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -30,7 +30,7 @@ internal import Foundation internal import Testing -@testable import MistDemoKit +@testable import MistKit // Raw-string JSON fixtures below intentionally use JSON-aligned indents that // don't match Swift source-indent steps; the rule isn't useful inside raw From 99b60fcdfb2815213f46c55f3480edcc676b5e65 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 25 May 2026 20:42:28 -0400 Subject: [PATCH 11/14] Address PR #381 review: move courier to MistKit, named failure accessors (#379) - Move web-courier long-poll into MistKit: new `Courier` namespace (`Courier.Transport` typealias + static `pollOnce`/`notifications`), `WebCourierPoller` convenience wrapper, and `URLSession.pollCourier` default transport. Transport is now an injectable closure (AsyncHTTPClient pluggable) mirroring `AssetUploader`. - Drop `CourierFrame` and the unused `waitForFrame()`; `pollOnce` returns `CourierNotification?` directly. - Restore `recordName` accessor on `RecordOperationFailure` and add `subscriptionID` on `SubscriptionOperationFailure` (named aliases for `identifier`). - Remove the `SubscriptionQuery` deprecated alias; repoint its doc reference to `Query`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Notifications/CourierFrame.swift | 74 -------- .../Notifications/WebCourierPoller.swift | 159 ----------------- .../Models/Notifications/Courier.swift | 160 ++++++++++++++++++ .../URLSession+CourierPoll.swift | 63 +++++++ .../Notifications/WebCourierPoller.swift | 85 ++++++++++ Sources/MistKit/Models/RecordTarget.swift | 6 + .../MistKit/Models/SubscriptionTarget.swift | 6 + .../Subscriptions/SubscriptionQuery.swift | 41 ----- .../Subscriptions/SubscriptionType.swift | 2 +- 9 files changed, 321 insertions(+), 275 deletions(-) delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift create mode 100644 Sources/MistKit/Models/Notifications/Courier.swift create mode 100644 Sources/MistKit/Models/Notifications/URLSession+CourierPoll.swift create mode 100644 Sources/MistKit/Models/Notifications/WebCourierPoller.swift delete mode 100644 Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift b/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift deleted file mode 100644 index a37c37ff..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Notifications/CourierFrame.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// CourierFrame.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 !os(WASI) - internal import Foundation - internal import MistKit - - /// A single raw response from a CloudKit web-courier long-poll. - /// - /// The web-courier wire format is **not** documented in Apple's CloudKit Web - /// Services REST reference — CloudKit JS consumes it internally — so this - /// frame preserves the unparsed bytes alongside a typed decode. See #379 - /// for the verified payload shape. - internal struct CourierFrame: Sendable { - /// HTTP status returned by the courier endpoint, when available. - internal let statusCode: Int? - /// The raw response body, preserved verbatim for wire-format discovery. - internal let raw: Data - - /// The body decoded as UTF-8 text, for logging during the discovery spike. - internal var bodyText: String { - String(decoding: raw, as: UTF8.self) - } - - /// Best-effort JSON decode of `raw`; `nil` when the body isn't JSON. - /// Computed (not stored) so the frame stays `Sendable` — `Any` isn't. - internal var json: Any? { - try? JSONSerialization.jsonObject(with: raw) - } - - /// Whether the frame carries a payload worth inspecting. A long-poll that - /// returns empty (a server-side keepalive / timeout) is not a delivery. - internal var isEmpty: Bool { - raw.isEmpty - } - - /// The frame decoded into a typed ``CourierNotification``, or `nil` if the - /// body isn't a recognizable notification (e.g. a keepalive). - internal var notification: CourierNotification? { - try? CourierNotification(data: raw) - } - - internal init(statusCode: Int?, raw: Data) { - self.statusCode = statusCode - self.raw = raw - } - } -#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift b/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift deleted file mode 100644 index 97bdf9d0..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Notifications/WebCourierPoller.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// WebCourierPoller.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 !os(WASI) - internal import Foundation - internal import MistKit - - #if canImport(FoundationNetworking) - internal import FoundationNetworking - #endif - - /// Long-polls a CloudKit `webcourierURL` to receive subscription-triggered - /// notifications without a device or APNs entitlement — the only fully - /// headless way to observe a push end-to-end. - /// - /// - Important: The transport defaults to a **dedicated** ephemeral - /// `URLSession`, and must **never** be the CloudKit API `ClientTransport`. - /// The courier host is distinct from `api.apple-cloudkit.com`; reusing the - /// CloudKit transport's HTTP/2 connection pool across the two hosts risks - /// **421 Misdirected Request** — the same hazard that makes MistKit upload - /// assets through a separate `AssetUploader` closure rather than the shared - /// transport (see CLAUDE.md, "Asset Upload Transport Design"). The - /// transport is injectable only so tests can drive the poller without a - /// live courier, not to share it with the CloudKit client. - internal struct WebCourierPoller { - /// A single courier long-poll round-trip: issue the request and return the - /// body plus response. Defaults to a dedicated ephemeral `URLSession`; - /// inject only to stub the courier in tests. See the type's 421 note — - /// never back this with the CloudKit `ClientTransport`. - internal typealias Transport = @Sendable (URLRequest) async throws -> (Data, URLResponse) - - private let courierURL: URL - private let perPollTimeout: TimeInterval - private let transport: Transport? - private let session: URLSession - - /// - Parameters: - /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. - /// - perPollTimeout: How long a single long-poll request waits before - /// the server (or this client) gives up and the caller polls again. - /// - transport: Optional injected round-trip used in place of the - /// dedicated `URLSession`. Leave `nil` in production. - internal init( - courierURL: URL, - perPollTimeout: TimeInterval = 30, - transport: Transport? = nil - ) { - self.courierURL = courierURL - self.perPollTimeout = perPollTimeout - self.transport = transport - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = perPollTimeout + 5 - configuration.waitsForConnectivity = false - self.session = URLSession(configuration: configuration) - } - - /// Issue one long-poll request and return whatever the courier responds - /// with. Returns even on an empty/keepalive body so the caller can decide - /// whether to poll again. - internal func pollOnce() async throws -> CourierFrame { - var request = URLRequest(url: courierURL) - request.httpMethod = "GET" - request.timeoutInterval = perPollTimeout - let data: Data - let response: URLResponse - if let transport { - (data, response) = try await transport(request) - } else { - (data, response) = try await session.data(for: request) - } - let statusCode = (response as? HTTPURLResponse)?.statusCode - return CourierFrame(statusCode: statusCode, raw: data) - } - - /// Long-poll repeatedly until a non-empty frame arrives or the task is - /// cancelled. The caller is expected to wrap this in a bounded timeout - /// (e.g. ``withTimeout(seconds:operation:)``) so a never-arriving push - /// can't hang the run. - internal func waitForFrame() async throws -> CourierFrame { - while true { - try Task.checkCancellation() - let frame = try await pollOnce() - if !frame.isEmpty { - return frame - } - // Empty body = keepalive/timeout; brief backoff before re-polling. - try await Task.sleep(for: .milliseconds(250)) - } - } - - /// A stream of decoded notifications — the Swift mirror of CloudKit JS's - /// `addNotificationListener`. Re-polls forever and yields each decoded - /// frame. Finishes when the consuming task is cancelled (e.g. via - /// `withTimeout`) or rethrows a transport error. - /// - /// No de-duplication: the courier is **consume-on-delivery** — each poll - /// pops exactly one queued notification and never redelivers it (verified - /// #379). De-duping on `notificationID` would be actively wrong: one change - /// matching N subscriptions enqueues N notifications that **share** a `nid` - /// but differ by `sid`, so keying on `nid` would drop the siblings. - internal func notifications() -> AsyncThrowingStream { - // Capture only Sendable state; rebuild the poller inside the task so the - // non-Sendable URLSession never crosses the concurrency boundary. - let courierURL = self.courierURL - let perPollTimeout = self.perPollTimeout - let transport = self.transport - return AsyncThrowingStream { continuation in - let task = Task { - let poller = WebCourierPoller( - courierURL: courierURL, - perPollTimeout: perPollTimeout, - transport: transport - ) - do { - while !Task.isCancelled { - let frame = try await poller.pollOnce() - guard let notification = frame.notification else { - // Keepalive/unparseable; brief backoff, then re-poll. - try await Task.sleep(for: .milliseconds(250)) - continue - } - continuation.yield(notification) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in task.cancel() } - } - } - } -#endif diff --git a/Sources/MistKit/Models/Notifications/Courier.swift b/Sources/MistKit/Models/Notifications/Courier.swift new file mode 100644 index 00000000..50d2a774 --- /dev/null +++ b/Sources/MistKit/Models/Notifications/Courier.swift @@ -0,0 +1,160 @@ +// +// Courier.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. +// + +#if !os(WASI) + public import Foundation + + #if canImport(FoundationNetworking) + public import FoundationNetworking + #endif + + /// Long-polls a CloudKit `webcourierURL` to receive subscription-triggered + /// notifications without a device or APNs entitlement — the only fully + /// headless way to observe a push end-to-end. + /// + /// `Courier` is a namespace: ``pollOnce(courierURL:perPollTimeout:transport:)`` + /// performs a single long-poll and + /// ``notifications(courierURL:perPollTimeout:transport:)`` streams them. Both + /// run their HTTP round-trip through a ``Transport`` closure that defaults to + /// `URLSession` (``Foundation/URLSession/pollCourier(_:timeout:)``); inject + /// your own to run the poll through a different client — e.g. + /// `AsyncHTTPClient` on a server — or to stub the courier in tests. + public enum Courier { + /// Closure that performs a single CloudKit web-courier long-poll: issue a + /// GET to the `webcourierURL` and return the raw HTTP response. + /// + /// **⚠️ CRITICAL: Transport Separation Required** + /// + /// Custom implementations MUST maintain connection-pool separation from the + /// CloudKit API: + /// - Use a transport distinct from the CloudKit API `ClientTransport` + /// - Do NOT share HTTP/2 connections with `api.apple-cloudkit.com` + /// + /// **Why Separate Connection Pools?** + /// + /// The web-courier host is distinct from the CloudKit API host + /// (`api.apple-cloudkit.com`). Reusing the same HTTP/2 connection for both + /// hosts causes 421 Misdirected Request errors due to HTTP/2's + /// host-validation rules — the same hazard that makes MistKit upload assets + /// through a separate ``AssetUploader`` rather than the shared API + /// transport. + /// + /// Returns the raw HTTP response (status code and body) without decoding; + /// `Courier` decodes the body into a ``CourierNotification``. + /// + /// - Parameters: + /// - courierURL: The `webcourierURL` to long-poll. + /// - timeout: How long a single long-poll request waits before giving up + /// so the caller can poll again. + /// - Returns: Tuple containing the optional HTTP status code and body. + /// - Throws: Any error that occurs during the poll. + public typealias Transport = + @Sendable (_ courierURL: URL, _ timeout: TimeInterval) async throws -> ( + statusCode: Int?, data: Data + ) + } + + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + extension Courier { + /// Issue one long-poll request and decode the result. + /// + /// Returns `nil` when the courier responds with an empty/keepalive body or + /// a body that isn't a recognizable notification, so the caller can decide + /// whether to poll again. + /// + /// - Parameters: + /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. + /// - perPollTimeout: How long the long-poll request waits before giving + /// up so the caller can poll again. + /// - transport: The HTTP round-trip used for the poll. Defaults to a + /// `URLSession`-backed implementation; inject your own to use a + /// different client or to stub the courier in tests. + public static func pollOnce( + courierURL: URL, + perPollTimeout: TimeInterval = 30, + transport: Transport? = nil + ) async throws -> CourierNotification? { + let perform = + transport ?? { url, timeout in + try await URLSession.shared.pollCourier(url, timeout: timeout) + } + let (_, data) = try await perform(courierURL, perPollTimeout) + return try? CourierNotification(data: data) + } + + /// A stream of decoded notifications — the Swift mirror of CloudKit JS's + /// `addNotificationListener`. Re-polls forever and yields each decoded + /// notification. Finishes when the consuming task is cancelled (e.g. via a + /// bounded timeout) or rethrows a transport error. + /// + /// No de-duplication: the courier is **consume-on-delivery** — each poll + /// pops exactly one queued notification and never redelivers it (verified + /// #379). De-duping on `notificationID` would be actively wrong: one change + /// matching N subscriptions enqueues N notifications that **share** a `nid` + /// but differ by `sid`, so keying on `nid` would drop the siblings. + /// + /// - Parameters: + /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. + /// - perPollTimeout: How long each long-poll request waits before + /// re-polling. + /// - transport: The HTTP round-trip used for each poll. Defaults to a + /// `URLSession`-backed implementation; inject your own to use a + /// different client or to stub the courier in tests. + public static func notifications( + courierURL: URL, + perPollTimeout: TimeInterval = 30, + transport: Transport? = nil + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + while !Task.isCancelled { + guard + let notification = try await pollOnce( + courierURL: courierURL, + perPollTimeout: perPollTimeout, + transport: transport + ) + else { + // Keepalive/unparseable; brief backoff, then re-poll. + try await Task.sleep(nanoseconds: 250_000_000) + continue + } + continuation.yield(notification) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + } +#endif diff --git a/Sources/MistKit/Models/Notifications/URLSession+CourierPoll.swift b/Sources/MistKit/Models/Notifications/URLSession+CourierPoll.swift new file mode 100644 index 00000000..c8e35c58 --- /dev/null +++ b/Sources/MistKit/Models/Notifications/URLSession+CourierPoll.swift @@ -0,0 +1,63 @@ +// +// URLSession+CourierPoll.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. +// + +public import Foundation + +#if canImport(FoundationNetworking) + public import FoundationNetworking +#endif + +#if !os(WASI) + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + extension URLSession { + /// Long-poll a CloudKit `webcourierURL` for a single response. + /// + /// Issues a GET bounded by `timeout` and returns the raw HTTP response + /// without decoding. ``Courier`` decodes the body into a + /// ``CourierNotification``. This is the default ``Courier/Transport`` + /// implementation. + /// + /// - Parameters: + /// - url: The `webcourierURL` to poll. + /// - timeout: How long the request waits before giving up. + /// - Returns: Tuple containing the optional HTTP status code and body. + /// - Throws: Error if the request fails. + public func pollCourier( + _ url: URL, + timeout: TimeInterval + ) async throws -> (statusCode: Int?, data: Data) { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + let (data, response) = try await self.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode + return (statusCode, data) + } + } +#endif diff --git a/Sources/MistKit/Models/Notifications/WebCourierPoller.swift b/Sources/MistKit/Models/Notifications/WebCourierPoller.swift new file mode 100644 index 00000000..d6709999 --- /dev/null +++ b/Sources/MistKit/Models/Notifications/WebCourierPoller.swift @@ -0,0 +1,85 @@ +// +// WebCourierPoller.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. +// + +#if !os(WASI) + public import Foundation + + #if canImport(FoundationNetworking) + public import FoundationNetworking + #endif + + /// A convenience wrapper around the ``Courier`` namespace that binds a + /// `webcourierURL` and per-poll timeout once, so callers can poll without + /// repeating them on every call. + /// + /// All work delegates to ``Courier/pollOnce(courierURL:perPollTimeout:transport:)`` + /// and ``Courier/notifications(courierURL:perPollTimeout:transport:)``. + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + public struct WebCourierPoller: Sendable { + private let courierURL: URL + private let perPollTimeout: TimeInterval + private let transport: Courier.Transport? + + /// - Parameters: + /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. + /// - perPollTimeout: How long a single long-poll request waits before + /// the server (or this client) gives up and the caller polls again. + /// - transport: Optional injected round-trip used in place of the + /// default `URLSession`. Leave `nil` to use the default. + public init( + courierURL: URL, + perPollTimeout: TimeInterval = 30, + transport: Courier.Transport? = nil + ) { + self.courierURL = courierURL + self.perPollTimeout = perPollTimeout + self.transport = transport + } + + /// Issue one long-poll request and decode the result. Returns `nil` on an + /// empty/keepalive or unrecognizable body so the caller can poll again. + public func pollOnce() async throws -> CourierNotification? { + try await Courier.pollOnce( + courierURL: courierURL, + perPollTimeout: perPollTimeout, + transport: transport + ) + } + + /// A stream of decoded notifications. Re-polls forever and yields each + /// decoded notification until the consuming task is cancelled. + public func notifications() -> AsyncThrowingStream { + Courier.notifications( + courierURL: courierURL, + perPollTimeout: perPollTimeout, + transport: transport + ) + } + } +#endif diff --git a/Sources/MistKit/Models/RecordTarget.swift b/Sources/MistKit/Models/RecordTarget.swift index 8d469839..bc45cb9f 100644 --- a/Sources/MistKit/Models/RecordTarget.swift +++ b/Sources/MistKit/Models/RecordTarget.swift @@ -41,6 +41,12 @@ public enum RecordTarget: OperationFailureTarget { } extension OperationFailure where Target == RecordTarget { + /// The record name of the record the operation failed on. + /// + /// A named alias for ``OperationFailure/identifier`` scoped to the + /// record target, matching CloudKit's `recordName` wire field. + public var recordName: String { identifier } + internal init(from schema: Components.Schemas.RecordOperationFailure) { self.init(identifier: schema.value2.recordName, common: schema.value1) } diff --git a/Sources/MistKit/Models/SubscriptionTarget.swift b/Sources/MistKit/Models/SubscriptionTarget.swift index 828a4e7f..ddbdf997 100644 --- a/Sources/MistKit/Models/SubscriptionTarget.swift +++ b/Sources/MistKit/Models/SubscriptionTarget.swift @@ -53,6 +53,12 @@ extension OperationFailure where Target == SubscriptionTarget { "could not find subscription we just created" } + /// The ID of the subscription the operation failed on. + /// + /// A named alias for ``OperationFailure/identifier`` scoped to the + /// subscription target, matching CloudKit's `subscriptionID` wire field. + public var subscriptionID: String { identifier } + /// `true` when CloudKit returned `INTERNAL_ERROR` with the exact reason /// string that, in practice, signals another subscription with matching /// properties (query/`firesOn` — *not* `subscriptionID`) already exists. diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift deleted file mode 100644 index 62d0d7a7..00000000 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionQuery.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// SubscriptionQuery.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. -// - -/// Deprecated alias for ``Query``. MistKit unified its query -/// representation so the same value powers -/// ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:database:)`` -/// and ``SubscriptionInfo/Kind/query(_:)``. -@available( - *, - deprecated, - renamed: "Query", - message: - "MistKit unified its query type — use `Query` for both queryRecords and SubscriptionInfo.Kind.query." -) -public typealias SubscriptionQuery = Query diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionType.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionType.swift index 55fe0d63..a946d68e 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionType.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionType.swift @@ -31,7 +31,7 @@ internal import MistKitOpenAPI /// The kind of change a CloudKit subscription watches for. public enum SubscriptionType: String, Codable, Sendable, CaseIterable { - /// Fires when records matching a query change (see ``SubscriptionQuery``). + /// Fires when records matching a query change (see ``Query``). case query /// Fires when any record in a record zone changes (see `zoneID`). case zone From 47f8a35bbbbf313b2509a68bce4e6d788580e6cf Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 25 May 2026 21:15:52 -0400 Subject: [PATCH 12/14] Address PR #381 review: layered WebCourierPoller inits, duplicateMarker let (#379) - `Courier.Transport` is no longer defaulted to nil. The `Courier` statics take a required transport; convenience/defaulting moves into a three-step `WebCourierPoller` initializer chain: transport -> session -> configuration (default `ephemeralConfiguration`: ephemeral, waitsForConnectivity false). - Default courier transport now uses a dedicated ephemeral URLSession rather than URLSession.shared, isolating held-open long-poll connections. - `Courier` statics drop the macOS 12 gate (no longer touch URLSession), so a custom transport works on any platform; only the URLSession-based inits gate. - `SubscriptionTarget.duplicateMarker` is now `static let` instead of a computed `var`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/Notifications/Courier.swift | 30 ++----- .../Notifications/WebCourierPoller.swift | 80 +++++++++++++++++-- .../MistKit/Models/SubscriptionTarget.swift | 4 +- 3 files changed, 81 insertions(+), 33 deletions(-) diff --git a/Sources/MistKit/Models/Notifications/Courier.swift b/Sources/MistKit/Models/Notifications/Courier.swift index 50d2a774..6aa3f7ea 100644 --- a/Sources/MistKit/Models/Notifications/Courier.swift +++ b/Sources/MistKit/Models/Notifications/Courier.swift @@ -30,10 +30,6 @@ #if !os(WASI) public import Foundation - #if canImport(FoundationNetworking) - public import FoundationNetworking - #endif - /// Long-polls a CloudKit `webcourierURL` to receive subscription-triggered /// notifications without a device or APNs entitlement — the only fully /// headless way to observe a push end-to-end. @@ -41,10 +37,9 @@ /// `Courier` is a namespace: ``pollOnce(courierURL:perPollTimeout:transport:)`` /// performs a single long-poll and /// ``notifications(courierURL:perPollTimeout:transport:)`` streams them. Both - /// run their HTTP round-trip through a ``Transport`` closure that defaults to - /// `URLSession` (``Foundation/URLSession/pollCourier(_:timeout:)``); inject - /// your own to run the poll through a different client — e.g. - /// `AsyncHTTPClient` on a server — or to stub the courier in tests. + /// run their HTTP round-trip through a ``Transport``. For the common + /// `URLSession` case, build a ``WebCourierPoller`` instead — it binds the URL + /// and a session-backed transport for you. public enum Courier { /// Closure that performs a single CloudKit web-courier long-poll: issue a /// GET to the `webcourierURL` and return the raw HTTP response. @@ -80,7 +75,6 @@ ) } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) extension Courier { /// Issue one long-poll request and decode the result. /// @@ -92,19 +86,13 @@ /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. /// - perPollTimeout: How long the long-poll request waits before giving /// up so the caller can poll again. - /// - transport: The HTTP round-trip used for the poll. Defaults to a - /// `URLSession`-backed implementation; inject your own to use a - /// different client or to stub the courier in tests. + /// - transport: The HTTP round-trip used for the poll. public static func pollOnce( courierURL: URL, perPollTimeout: TimeInterval = 30, - transport: Transport? = nil + transport: Transport ) async throws -> CourierNotification? { - let perform = - transport ?? { url, timeout in - try await URLSession.shared.pollCourier(url, timeout: timeout) - } - let (_, data) = try await perform(courierURL, perPollTimeout) + let (_, data) = try await transport(courierURL, perPollTimeout) return try? CourierNotification(data: data) } @@ -123,13 +111,11 @@ /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. /// - perPollTimeout: How long each long-poll request waits before /// re-polling. - /// - transport: The HTTP round-trip used for each poll. Defaults to a - /// `URLSession`-backed implementation; inject your own to use a - /// different client or to stub the courier in tests. + /// - transport: The HTTP round-trip used for each poll. public static func notifications( courierURL: URL, perPollTimeout: TimeInterval = 30, - transport: Transport? = nil + transport: @escaping Transport ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task { diff --git a/Sources/MistKit/Models/Notifications/WebCourierPoller.swift b/Sources/MistKit/Models/Notifications/WebCourierPoller.swift index d6709999..23124620 100644 --- a/Sources/MistKit/Models/Notifications/WebCourierPoller.swift +++ b/Sources/MistKit/Models/Notifications/WebCourierPoller.swift @@ -34,34 +34,98 @@ public import FoundationNetworking #endif - /// A convenience wrapper around the ``Courier`` namespace that binds a - /// `webcourierURL` and per-poll timeout once, so callers can poll without - /// repeating them on every call. + /// Binds a `webcourierURL` and a ``Courier/Transport`` so callers can + /// long-poll for subscription-triggered notifications without a device or + /// APNs entitlement — the only fully headless way to observe a push + /// end-to-end. + /// + /// Construct it three ways, from lowest- to highest-level: + /// - ``init(courierURL:perPollTimeout:transport:)`` — supply any + /// ``Courier/Transport`` (e.g. an `AsyncHTTPClient`-backed closure, or a + /// test stub). Available on every platform. + /// - ``init(courierURL:perPollTimeout:session:)`` — back the poll with a + /// `URLSession` you provide; the session is reused across polls. + /// - ``init(courierURL:perPollTimeout:configuration:)`` — build a dedicated + /// `URLSession` from a configuration that defaults to + /// ``ephemeralConfiguration``. /// /// All work delegates to ``Courier/pollOnce(courierURL:perPollTimeout:transport:)`` /// and ``Courier/notifications(courierURL:perPollTimeout:transport:)``. - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) public struct WebCourierPoller: Sendable { + /// The default courier session configuration: ephemeral (no persistent + /// cache or cookies) with `waitsForConnectivity == false`, so a poll fails + /// fast rather than blocking when connectivity is unavailable. Being a + /// dedicated session, it keeps the courier's held-open long-poll + /// connections isolated from `URLSession.shared` and from the CloudKit API + /// `ClientTransport`. + public static var ephemeralConfiguration: URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.waitsForConnectivity = false + return configuration + } + private let courierURL: URL private let perPollTimeout: TimeInterval - private let transport: Courier.Transport? + private let transport: Courier.Transport + /// Build a poller from an explicit ``Courier/Transport``. + /// /// - Parameters: /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. /// - perPollTimeout: How long a single long-poll request waits before /// the server (or this client) gives up and the caller polls again. - /// - transport: Optional injected round-trip used in place of the - /// default `URLSession`. Leave `nil` to use the default. + /// - transport: The HTTP round-trip used for each poll. public init( courierURL: URL, perPollTimeout: TimeInterval = 30, - transport: Courier.Transport? = nil + transport: @escaping Courier.Transport ) { self.courierURL = courierURL self.perPollTimeout = perPollTimeout self.transport = transport } + /// Build a poller backed by a `URLSession`, reused across every poll so its + /// connection pool is shared only among this poller's long-polls. + /// + /// - Parameters: + /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. + /// - perPollTimeout: How long a single long-poll request waits before the + /// caller polls again. + /// - session: The session used to issue each poll. + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + public init( + courierURL: URL, + perPollTimeout: TimeInterval = 30, + session: URLSession + ) { + self.init(courierURL: courierURL, perPollTimeout: perPollTimeout) { url, timeout in + try await session.pollCourier(url, timeout: timeout) + } + } + + /// Build a poller backed by a dedicated `URLSession` created from + /// `configuration`. + /// + /// - Parameters: + /// - courierURL: The `webcourierURL` returned by `createAPNsToken`. + /// - perPollTimeout: How long a single long-poll request waits before the + /// caller polls again. + /// - configuration: The configuration for the dedicated session. + /// Defaults to ``ephemeralConfiguration``. + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + public init( + courierURL: URL, + perPollTimeout: TimeInterval = 30, + configuration: URLSessionConfiguration = WebCourierPoller.ephemeralConfiguration + ) { + self.init( + courierURL: courierURL, + perPollTimeout: perPollTimeout, + session: URLSession(configuration: configuration) + ) + } + /// Issue one long-poll request and decode the result. Returns `nil` on an /// empty/keepalive or unrecognizable body so the caller can poll again. public func pollOnce() async throws -> CourierNotification? { diff --git a/Sources/MistKit/Models/SubscriptionTarget.swift b/Sources/MistKit/Models/SubscriptionTarget.swift index ddbdf997..c191d1aa 100644 --- a/Sources/MistKit/Models/SubscriptionTarget.swift +++ b/Sources/MistKit/Models/SubscriptionTarget.swift @@ -49,9 +49,7 @@ extension OperationFailure where Target == SubscriptionTarget { /// match risks false positives on unrelated future `INTERNAL_ERROR` /// variants. Revisit only if `mistdemo probe-duplicate-subscription` /// surfaces wording variants. - internal static var duplicateMarker: String { - "could not find subscription we just created" - } + internal static let duplicateMarker = "could not find subscription we just created" /// The ID of the subscription the operation failed on. /// From 3b84901cd775dfc0fb928ce4b4cc7e52c0badc64 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 25 May 2026 22:34:46 -0400 Subject: [PATCH 13/14] Address PR #381 review: DocC links, courier safety, +11 tests (#379) Review fixes: - Fix stale DocC links in `createSubscription` (query/zone/database factory signatures now resolve). - `Courier.pollOnce` throws `CloudKitError.httpError` on a non-2xx status instead of returning nil forever, so a persistent courier failure surfaces rather than hammering the endpoint. - Add tests: subscription delete-acknowledgement filtering, `.database` subscription schema + Codable round-trips, token 400/401 failure mapping, and `Courier.pollOnce` status-code behavior. Courier tidy-ups (this branch, pre-review): - `URLSession.courierTransport` accessor vending a `Courier.Transport`. - Guard `waitsForConnectivity` behind `#if !canImport(FoundationNetworking)` (get-only on swift-corelibs-foundation). - Add `Returns:`/`Throws:` doc sections to the `Courier` long-poll APIs. Also trim MockBackend.swift to satisfy the file_length limit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MistDemoTests/Server/MockBackend.swift | 3 +- .../CloudKitService+ModifySubscriptions.swift | 10 +- .../Models/Notifications/Courier.swift | 13 +- .../URLSession+CourierPoll.swift | 11 ++ .../Notifications/WebCourierPoller.swift | 14 +- ...viceTests.Subscriptions+SuccessCases.swift | 26 +++- ...dKitServiceTests.Tokens+FailureCases.swift | 127 ++++++++++++++++++ .../Models/Notifications/CourierTests.swift | 78 +++++++++++ .../SubscriptionConversionTests.swift | 23 ++++ .../SubscriptionInfoCodableTests.swift | 84 ++++++++++++ 10 files changed, 378 insertions(+), 11 deletions(-) create mode 100644 Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift create mode 100644 Tests/MistKitTests/Models/Notifications/CourierTests.swift create mode 100644 Tests/MistKitTests/Models/Subscriptions/SubscriptionInfoCodableTests.swift diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index aff87595..9be5e842 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -48,8 +48,7 @@ internal private(set) var lastRegisterToken: RegisterTokenCall? private var pendingError: String? - /// Subscriptions returned by the list/lookup/modify stubs. Tests can seed - /// this; defaults to one query subscription. + /// Stub subscriptions (tests can seed); defaults to one query subscription. private var stubSubscriptions: [SubscriptionInfo] = [ .query(subscriptionID: "stub-sub", recordType: "Note", firesOn: [.create]) ] diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift index 29052444..b32c1942 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ModifySubscriptions.swift @@ -71,11 +71,16 @@ extension CloudKitService { } } + // The query factory's full DocC link can't wrap and exceeds the line limit; + // disabling here keeps it a single resolvable token. + // swiftlint:disable line_length /// Create a single subscription. /// /// Convenience wrapper over ``modifySubscriptions(_:database:)``. Build the - /// `subscription` with ``SubscriptionInfo/query(subscriptionID:recordType:filters:sortBy:firesOn:)`` - /// or ``SubscriptionInfo/zone(subscriptionID:zoneID:firesOn:)``. + /// `subscription` with + /// ``SubscriptionInfo/query(subscriptionID:recordType:filters:sortBy:firesOn:firesOnce:notificationInfo:)``, + /// ``SubscriptionInfo/zone(subscriptionID:zoneID:notificationInfo:)``, or + /// ``SubscriptionInfo/database(subscriptionID:notificationInfo:)``. /// /// - Parameters: /// - subscription: The subscription to create. @@ -92,6 +97,7 @@ extension CloudKitService { _ subscription: SubscriptionInfo, database: Database ) async throws(CloudKitError) -> SubscriptionInfo { + // swiftlint:enable line_length let results = try await modifySubscriptions([.create(subscription)], database: database) guard let created = results.first else { throw CloudKitError.invalidResponse diff --git a/Sources/MistKit/Models/Notifications/Courier.swift b/Sources/MistKit/Models/Notifications/Courier.swift index 6aa3f7ea..e933da45 100644 --- a/Sources/MistKit/Models/Notifications/Courier.swift +++ b/Sources/MistKit/Models/Notifications/Courier.swift @@ -87,12 +87,21 @@ /// - perPollTimeout: How long the long-poll request waits before giving /// up so the caller can poll again. /// - transport: The HTTP round-trip used for the poll. + /// - Returns: The decoded notification, or `nil` for an empty/keepalive or + /// unrecognizable body. + /// - Throws: ``CloudKitError/httpError(statusCode:)`` when the courier + /// responds with a non-2xx status (e.g. an expired token), so a + /// persistent failure surfaces instead of looping; otherwise any error + /// thrown by `transport`. public static func pollOnce( courierURL: URL, perPollTimeout: TimeInterval = 30, transport: Transport ) async throws -> CourierNotification? { - let (_, data) = try await transport(courierURL, perPollTimeout) + let (statusCode, data) = try await transport(courierURL, perPollTimeout) + if let statusCode, !(200...299).contains(statusCode) { + throw CloudKitError.httpError(statusCode: statusCode) + } return try? CourierNotification(data: data) } @@ -112,6 +121,8 @@ /// - perPollTimeout: How long each long-poll request waits before /// re-polling. /// - transport: The HTTP round-trip used for each poll. + /// - Returns: A stream that yields each decoded notification until the + /// consuming task is cancelled or `transport` throws. public static func notifications( courierURL: URL, perPollTimeout: TimeInterval = 30, diff --git a/Sources/MistKit/Models/Notifications/URLSession+CourierPoll.swift b/Sources/MistKit/Models/Notifications/URLSession+CourierPoll.swift index c8e35c58..570d31ab 100644 --- a/Sources/MistKit/Models/Notifications/URLSession+CourierPoll.swift +++ b/Sources/MistKit/Models/Notifications/URLSession+CourierPoll.swift @@ -36,6 +36,17 @@ public import Foundation #if !os(WASI) @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) extension URLSession { + /// A ``Courier/Transport`` backed by this session: each poll runs through + /// ``pollCourier(_:timeout:)``, so the session (and its connection pool) is + /// reused across polls. Pass it to + /// ``WebCourierPoller/init(courierURL:perPollTimeout:transport:)`` or the + /// `Courier` long-poll APIs. + public var courierTransport: Courier.Transport { + { url, timeout in + try await self.pollCourier(url, timeout: timeout) + } + } + /// Long-poll a CloudKit `webcourierURL` for a single response. /// /// Issues a GET bounded by `timeout` and returns the raw HTTP response diff --git a/Sources/MistKit/Models/Notifications/WebCourierPoller.swift b/Sources/MistKit/Models/Notifications/WebCourierPoller.swift index 23124620..e684c4a2 100644 --- a/Sources/MistKit/Models/Notifications/WebCourierPoller.swift +++ b/Sources/MistKit/Models/Notifications/WebCourierPoller.swift @@ -60,7 +60,11 @@ /// `ClientTransport`. public static var ephemeralConfiguration: URLSessionConfiguration { let configuration = URLSessionConfiguration.ephemeral - configuration.waitsForConnectivity = false + // `waitsForConnectivity` is get-only on swift-corelibs-foundation + // (FoundationNetworking); only Apple's Foundation allows setting it. + #if !canImport(FoundationNetworking) + configuration.waitsForConnectivity = false + #endif return configuration } @@ -99,9 +103,11 @@ perPollTimeout: TimeInterval = 30, session: URLSession ) { - self.init(courierURL: courierURL, perPollTimeout: perPollTimeout) { url, timeout in - try await session.pollCourier(url, timeout: timeout) - } + self.init( + courierURL: courierURL, + perPollTimeout: perPollTimeout, + transport: session.courierTransport + ) } /// Build a poller backed by a dedicated `URLSession` created from diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift index 1cd43e49..c606a6fd 100644 --- a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+SuccessCases.swift @@ -111,16 +111,38 @@ extension CloudKitServiceTests.Subscriptions { #expect(created.query?.recordType == "Article") } - @Test("deleteSubscription() completes against an empty subscriptions response") + @Test("deleteSubscription() completes against a deletion-acknowledgement response") internal func deleteCompletes() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("CloudKitService is not available on this operating system.") return } + // CloudKit echoes a deleted subscription as a bare `{ subscriptionID }` + // entry with no `subscriptionType` — the real deletion-ack shape. let service = try CloudKitServiceTests.Subscriptions.makeService( - returningJSON: #"{ "subscriptions": [] }"# + returningJSON: #"{ "subscriptions": [{ "subscriptionID": "query-sub" }] }"# ) try await service.deleteSubscription(id: "query-sub", database: Self.database) } + + @Test("modifySubscriptions() filters deletion acknowledgements out of its results") + internal func modifyFiltersDeletionAcks() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + // A bare `{ subscriptionID }` ack (no `subscriptionType`) is a deletion + // acknowledgement, not a result, so it is dropped from the returned array. + let service = try CloudKitServiceTests.Subscriptions.makeService( + returningJSON: #"{ "subscriptions": [{ "subscriptionID": "query-sub" }] }"# + ) + + let results = try await service.modifySubscriptions( + [.delete(subscriptionID: "query-sub")], + database: Self.database + ) + + #expect(results.isEmpty) + } } } diff --git a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift new file mode 100644 index 00000000..3ac35ca6 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift @@ -0,0 +1,127 @@ +// +// CloudKitServiceTests.Tokens+FailureCases.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.Tokens { + @Suite("Failure Cases") + internal struct FailureCases { + private static let database: Database = .public(.prefers(.serverToServer)) + + private static let badRequestJSON = #""" + { "serverErrorCode": "BAD_REQUEST", "reason": "clientId is required" } + """# + + private static let authFailedJSON = #""" + { "serverErrorCode": "AUTHENTICATION_FAILED", "reason": "bad token" } + """# + + @Test("createAPNsToken() maps a 400 BAD_REQUEST to .badRequest") + internal func createMapsBadRequest() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Tokens.makeService( + statusCode: 400, + json: Self.badRequestJSON + ) + + do { + _ = try await service.createAPNsToken( + environment: .development, + database: Self.database + ) + Issue.record("expected createAPNsToken to throw") + } catch let error as CloudKitError { + guard case .badRequest(let reason) = error else { + Issue.record("expected .badRequest, got \(error)") + return + } + #expect(reason == "clientId is required") + } + } + + @Test("createAPNsToken() maps a 401 to .httpErrorWithDetails") + internal func createMapsUnauthorized() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Tokens.makeService( + statusCode: 401, + json: Self.authFailedJSON + ) + + do { + _ = try await service.createAPNsToken( + environment: .development, + database: Self.database + ) + Issue.record("expected createAPNsToken to throw") + } catch let error as CloudKitError { + guard case .httpErrorWithDetails(let statusCode, let serverErrorCode, _) = error else { + Issue.record("expected .httpErrorWithDetails, got \(error)") + return + } + #expect(statusCode == 401) + #expect(serverErrorCode == "AUTHENTICATION_FAILED") + } + } + + @Test("registerAPNsToken() maps a 400 BAD_REQUEST to .badRequest") + internal func registerMapsBadRequest() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.Tokens.makeService( + statusCode: 400, + json: Self.badRequestJSON + ) + + do { + try await service.registerAPNsToken( + "abcdef0123456789", + environment: .development, + database: Self.database + ) + Issue.record("expected registerAPNsToken to throw") + } catch let error as CloudKitError { + guard case .badRequest = error else { + Issue.record("expected .badRequest, got \(error)") + return + } + } + } + } +} diff --git a/Tests/MistKitTests/Models/Notifications/CourierTests.swift b/Tests/MistKitTests/Models/Notifications/CourierTests.swift new file mode 100644 index 00000000..8d8c87e7 --- /dev/null +++ b/Tests/MistKitTests/Models/Notifications/CourierTests.swift @@ -0,0 +1,78 @@ +// +// CourierTests.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. +// + +#if !os(WASI) + internal import Foundation + internal import Testing + + @testable import MistKit + + @Suite("Courier") + internal struct CourierTests { + private static func courierURL() throws -> URL { + try #require(URL(string: "https://webcourier.icloud.com/poll")) + } + + @Test("pollOnce throws on a non-2xx status instead of looping silently") + internal func pollOnceThrowsOnHTTPError() async throws { + let url = try Self.courierURL() + let transport: Courier.Transport = { _, _ in (statusCode: 401, data: Data()) } + do { + _ = try await Courier.pollOnce(courierURL: url, transport: transport) + Issue.record("expected pollOnce to throw on a 401") + } catch let error as CloudKitError { + guard case .httpError(let statusCode) = error else { + Issue.record("expected .httpError, got \(error)") + return + } + #expect(statusCode == 401) + } + } + + @Test("pollOnce returns nil for an empty keepalive body") + internal func pollOnceReturnsNilOnEmptyBody() async throws { + let url = try Self.courierURL() + let transport: Courier.Transport = { _, _ in (statusCode: 200, data: Data()) } + let result = try await Courier.pollOnce(courierURL: url, transport: transport) + #expect(result == nil) + } + + @Test("pollOnce decodes a notification from a 2xx body") + internal func pollOnceDecodesNotification() async throws { + let url = try Self.courierURL() + let body = #"{"ck":{"qry":{"fo":1,"sid":"s","rid":"r"}}}"# + let transport: Courier.Transport = { _, _ in + (statusCode: 200, data: Data(body.utf8)) + } + let result = try await Courier.pollOnce(courierURL: url, transport: transport) + let notification = try #require(result) + #expect(notification.reason == .recordCreated) + } + } +#endif diff --git a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift index f852d34f..c1b0e775 100644 --- a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift +++ b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift @@ -101,6 +101,29 @@ internal struct SubscriptionConversionTests { #expect(recovered.firesOn.isEmpty) } + @Test("database SubscriptionInfo round-trips through the OpenAPI schema") + internal func databaseRoundTrip() throws { + let info = SubscriptionInfo.database(subscriptionID: "db-1") + + let schema = info.schema + // A database-scoped subscription is a `zone` type with `zoneWide: true` + // and no `zoneID`. + #expect(schema.subscriptionType == .zone) + #expect(schema.zoneWide == true) + #expect(schema.zoneID == nil) + #expect(schema.query == nil) + #expect(schema.firesOn == nil) + + let recovered = try SubscriptionInfo(from: schema) + #expect(recovered.subscriptionID == "db-1") + #expect(recovered.subscriptionType == .zone) + #expect(recovered.zoneID == nil) + guard case .database = recovered.kind else { + Issue.record("expected .database kind, got \(recovered.kind)") + return + } + } + @Test("missing subscriptionID is a conversion failure") internal func missingIDThrows() throws { expectThrow(ConversionError.subscriptionMissingID) { diff --git a/Tests/MistKitTests/Models/Subscriptions/SubscriptionInfoCodableTests.swift b/Tests/MistKitTests/Models/Subscriptions/SubscriptionInfoCodableTests.swift new file mode 100644 index 00000000..62e7d0de --- /dev/null +++ b/Tests/MistKitTests/Models/Subscriptions/SubscriptionInfoCodableTests.swift @@ -0,0 +1,84 @@ +// +// SubscriptionInfoCodableTests.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 + +@Suite("SubscriptionInfo Codable") +internal struct SubscriptionInfoCodableTests { + /// CloudKit collapses a database-wide subscription into + /// `subscriptionType: zone` with `zoneWide: true` and no `zoneID`, so the + /// decoder must recover it as `.database`. + @Test("decodes a zoneWide zone subscription as .database") + internal func decodesDatabase() throws { + let json = #""" + { "subscriptionType": "zone", "zoneWide": true, "subscriptionID": "db-1" } + """# + + let decoded = try JSONDecoder().decode(SubscriptionInfo.self, from: Data(json.utf8)) + + #expect(decoded.subscriptionID == "db-1") + #expect(decoded.subscriptionType == .zone) + #expect(decoded.zoneID == nil) + guard case .database = decoded.kind else { + Issue.record("expected .database kind, got \(decoded.kind)") + return + } + } + + @Test("encodes .database as zoneWide:true with no zoneID") + internal func encodesDatabase() throws { + let info = SubscriptionInfo.database(subscriptionID: "db-1") + + let data = try JSONEncoder().encode(info) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + #expect(object["subscriptionType"] as? String == "zone") + #expect(object["zoneWide"] as? Bool == true) + #expect(object["zoneID"] == nil) + } + + @Test("round-trips a .database subscription through Codable") + internal func databaseRoundTrips() throws { + let info = SubscriptionInfo.database(subscriptionID: "db-1") + + let data = try JSONEncoder().encode(info) + let decoded = try JSONDecoder().decode(SubscriptionInfo.self, from: data) + + #expect(decoded.subscriptionID == "db-1") + guard case .database = decoded.kind else { + Issue.record("expected .database kind, got \(decoded.kind)") + return + } + } +} From d652f1907fdafdd6bcdc6d4dd5aa2c8ea3b67a5b Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 25 May 2026 22:36:14 -0400 Subject: [PATCH 14/14] git subrepo push Examples/BushelCloud subrepo: subdir: "Examples/BushelCloud" merged: "66bbe46" upstream: origin: "git@github.com:brightdigit/BushelCloud.git" branch: "mistkit" commit: "66bbe46" git-subrepo: version: "0.4.9" origin: "https://github.com/Homebrew/brew" commit: "6f293daa9f" --- Examples/BushelCloud/.gitrepo | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/BushelCloud/.gitrepo b/Examples/BushelCloud/.gitrepo index e3d0b782..5724f840 100644 --- a/Examples/BushelCloud/.gitrepo +++ b/Examples/BushelCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/BushelCloud.git branch = mistkit - commit = fa92cff25dd3bd632c567e19e383689f74f86056 - parent = 86e2fe6ab292be4b005126f12b1520e6c60c22dd + commit = 66bbe468d8a80c375b1a03c229f68a510eaf4f0a + parent = 3b84901cd775dfc0fb928ce4b4cc7e52c0badc64 method = merge cmdver = 0.4.9