From 7d59813f2ddc1a840d9914c33bce97dc26175696 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 22 May 2026 17:09:07 -0400 Subject: [PATCH 01/10] Tag and validate ambiguous FieldValue scalar types (#375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloudKit infers a field's type from its value structure, so a `.date` serialized as a bare millisecond number was read as INT64 and rejected against TIMESTAMP schema fields with BAD_REQUEST — blocking every CelestraCloud record write that includes a timestamp. Write path: add the scalar types to FieldValueRequest.type (openapi.yaml, regenerated MistKitOpenAPI) and emit `type` for the ambiguous scalars in makeScalarRequest — .date→TIMESTAMP, .bytes→BYTES, .double→DOUBLE. Object/array-shaped values and STRING/INT64 stay untagged. Read path: the generated value oneOf is undiscriminated (first-match-wins: String→Int64→Double→Bytes→Date), so a whole-millisecond TIMESTAMP decoded as Int64Value and read back as .int64 even with `type` present. Response conversion now honors an explicit scalar `type` over the decoded case (FieldValue+Components+Scalar.swift), recovering TIMESTAMP/DOUBLE from any numeric case and BYTES from any string case. When a scalar `type` contradicts the value's category, conversion throws the new ConversionError.typeValueMismatch instead of silently coercing, matching the existing fail-loud unmappableFieldValue philosophy. Strict validation is scoped to scalar tags; complex/list tags stay lenient (follow-up: #376). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 17 +- Sources/MistKit/Models/ConversionError.swift | 7 + .../FieldValue+Components+Scalar.swift | 174 ++++++++++++++++++ .../FieldValues/FieldValue+Components.swift | 28 +-- ...Components.Schemas.FieldValueRequest.swift | 16 +- Sources/MistKitOpenAPI/Types.swift | 27 ++- ...FieldValueConversionTests+BasicTypes.swift | 10 + ...ldValueConversionTests+ResponseTypes.swift | 136 ++++++++++++++ openapi.yaml | 11 +- 9 files changed, 386 insertions(+), 40 deletions(-) create mode 100644 Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift create mode 100644 Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift diff --git a/CLAUDE.md b/CLAUDE.md index 3a898546..6df25741 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,11 +116,22 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev **Type Layers:** 1. **Domain Layer**: `FieldValue` enum - Pure Swift types, no API metadata (`Sources/MistKit/Models/FieldValues/FieldValue.swift`) -2. **API Request Layer**: `FieldValueRequest` - No type field, CloudKit infers type from value structure +2. **API Request Layer**: `FieldValueRequest` - Optional type field; CloudKit infers type from value structure, except for ambiguous scalars (see below) and IN/NOT_IN list filters, which are tagged explicitly 3. **API Response Layer**: `FieldValueResponse` - Optional type field for explicit type information +**Request type tagging (issue #375):** Most request values omit `type` and let CloudKit infer it from the value structure. Three scalar types are ambiguous on the wire and **must** carry an explicit `type`, otherwise CloudKit infers the wrong type and rejects the write with `BAD_REQUEST`: +- `TIMESTAMP` (`.date`) — a millisecond number, otherwise read as `INT64`/`DOUBLE` +- `BYTES` (`.bytes`) — a base64 string, otherwise read as `STRING` +- `DOUBLE` (`.double`) — a whole-valued double serializes without a fraction, otherwise read as `INT64` + +Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in `makeScalarRequest` (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional. + +**Response type recovery (issue #375):** The generated `value` `oneOf` is *undiscriminated* — the decoder tries cases first-match-wins (`String → Int64 → Double → Bytes → Date`), so a whole-millisecond `TIMESTAMP` decodes as `Int64Value` and a base64 `BYTES` string decodes as `StringValue`. The response conversion therefore honors an explicit `type` *over* the decoded case (`makeTypedScalar` in `FieldValue+Components+Scalar.swift`). For the genuinely-ambiguous scalars whose correct interpretation differs from inference it produces the typed value directly: `TIMESTAMP`/`DOUBLE` from any numeric case, `BYTES` from any string case. `INT64`/`STRING` validate the category then defer to inference (which already yields them, and for `INT64` avoids truncating a fractional number). When `type` is absent it falls back to first-match-wins inference (`makeInferredScalar`), which is lossy for the ambiguous scalars (BYTES→`.string`, whole-number TIMESTAMP→`.int64`). + +When a scalar `type` *contradicts* the value's category — a numeric type (`TIMESTAMP`/`DOUBLE`/`INT64`) over a non-number, or a string type (`STRING`/`BYTES`) over a non-string — the response is internally inconsistent and the conversion **throws** `ConversionError.typeValueMismatch` (via `requireNumeric`/`requireString`) rather than coercing to the value's shape. This matches the codebase's existing fail-loud `unmappableFieldValue` philosophy. The strict check is scoped to **scalar** type tags; complex/list declared types (`REFERENCE`/`ASSET`/`LOCATION`/`LIST`) are left to the value's self-describing structure and are not validated against the tag. + **Why Separate Request/Response Types?** -- CloudKit API has asymmetric behavior: requests omit type field, responses may include it +- CloudKit API has asymmetric behavior: requests tag type only when ambiguous, responses may always include it - OpenAPI schema accurately models this asymmetry (openapi.yaml:867-920) - Swift code generation produces type-safe request/response types - Compiler prevents accidentally using response types in requests @@ -134,7 +145,7 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev **Conversion:** - Request conversion: `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` converts domain `FieldValue` → `FieldValueRequest` -- Response conversion: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` converts `FieldValueResponse` → domain `FieldValue` +- Response conversion: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` (entry point + complex types) and `FieldValue+Components+Scalar.swift` (scalar type recovery) convert `FieldValueResponse` → domain `FieldValue` ### Modern Swift Features to Utilize - Swift Concurrency (async/await) for all network operations diff --git a/Sources/MistKit/Models/ConversionError.swift b/Sources/MistKit/Models/ConversionError.swift index 6074b486..3cab708f 100644 --- a/Sources/MistKit/Models/ConversionError.swift +++ b/Sources/MistKit/Models/ConversionError.swift @@ -42,6 +42,10 @@ public import Foundation public enum ConversionError: LocalizedError, Sendable, Equatable { /// A field value's structure matched no known `FieldValue` case. case unmappableFieldValue(fieldName: String, value: String, type: String?) + /// A response declared a scalar `type` that the field's value cannot satisfy + /// (e.g. a `TIMESTAMP` tag over a string value). Such a response is internally + /// inconsistent and cannot be faithfully represented. + case typeValueMismatch(fieldName: String, declaredType: String, value: String) /// A list element matched no known `FieldValue` case. case unmappableListItem(fieldName: String, item: String) /// A nested-list element was not one of the supported basic types. @@ -73,6 +77,9 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { case .unmappableFieldValue(let fieldName, let value, let type): return "Unmappable FieldValue for field '\(fieldName)' " + "(value: \(value), type: \(type ?? "nil"))" + case .typeValueMismatch(let fieldName, let declaredType, let value): + return "Field '\(fieldName)' declared type \(declaredType) " + + "but its value is incompatible (\(value))" case .unmappableListItem(let fieldName, let item): return "Unmappable list item for field '\(fieldName)' (\(item))" case .unmappableNestedListItem(let fieldName, let item): diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift new file mode 100644 index 00000000..2809dde3 --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift @@ -0,0 +1,174 @@ +// +// FieldValue+Components+Scalar.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 + +/// Scalar-value conversions for `FieldValue` ← `Components.Schemas` response types. +extension FieldValue { + internal static func makeSimpleFieldValue( + from value: Components.Schemas.FieldValueResponse.valuePayload, + type fieldType: Components.Schemas.FieldValueResponse._typePayload?, + fieldName: String + ) throws(ConversionError) -> FieldValue? { + // The `value` oneOf is undiscriminated and decoded first-match-wins + // (String → Int64 → Double → Bytes → Date), so a whole-millisecond TIMESTAMP + // arrives as Int64Value and a BYTES base64 string arrives as StringValue. + // When CloudKit supplies an explicit scalar `type`, honor it over the decoded case + // so these ambiguous scalars round-trip correctly; otherwise infer from the case. + if let typed = try makeTypedScalar(from: value, type: fieldType, fieldName: fieldName) { + return typed + } + return makeInferredScalar(from: value) + } + + /// Build a scalar `FieldValue` from an explicit CloudKit `type`, recovering the value + /// from whichever undiscriminated `oneOf` case it happened to decode into. + /// + /// All five scalar types are validated against the value's category (numeric vs. string). + /// A declared scalar type whose value can't satisfy it (e.g. `TIMESTAMP` over a string) + /// is an internally inconsistent response and throws ``ConversionError/typeValueMismatch`` + /// rather than silently coercing to the value's shape. Only the genuinely ambiguous + /// scalars (`TIMESTAMP`/`DOUBLE`/`BYTES`) produce a value here; `INT64`/`STRING` validate + /// the category then return nil to defer to inference — which already yields the right + /// case and, for `INT64`, avoids truncating a fractional number. A `nil` or complex/list + /// `type` returns nil and is handled by inference or `makeComplexFieldValue`. + private static func makeTypedScalar( + from value: Components.Schemas.FieldValueResponse.valuePayload, + type fieldType: Components.Schemas.FieldValueResponse._typePayload?, + fieldName: String + ) throws(ConversionError) -> FieldValue? { + switch fieldType { + case .TIMESTAMP: + let millis = try requireNumeric(value, fieldName: fieldName, declaredType: "TIMESTAMP") + return .date(Date(timeIntervalSince1970: millis / 1_000)) + case .DOUBLE: + return .double(try requireNumeric(value, fieldName: fieldName, declaredType: "DOUBLE")) + case .INT64: + _ = try requireNumeric(value, fieldName: fieldName, declaredType: "INT64") + return nil + case .BYTES: + return .bytes(try requireString(value, fieldName: fieldName, declaredType: "BYTES")) + case .STRING: + _ = try requireString(value, fieldName: fieldName, declaredType: "STRING") + return nil + default: + 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( + _ value: Components.Schemas.FieldValueResponse.valuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Double { + guard let number = numericValue(from: value) else { + let failure = ConversionError.typeValueMismatch( + fieldName: fieldName, + declaredType: declaredType, + value: "\(value)" + ) + try failure.reportAndThrow() + } + return number + } + + /// Require that `value` carries a string, throwing ``ConversionError/typeValueMismatch`` + /// when a string `type` is declared over a non-string value. + private static func requireString( + _ value: Components.Schemas.FieldValueResponse.valuePayload, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> String { + guard let string = stringValue(from: value) else { + let failure = ConversionError.typeValueMismatch( + fieldName: fieldName, + declaredType: declaredType, + value: "\(value)" + ) + try failure.reportAndThrow() + } + return string + } + + /// Infer a scalar `FieldValue` from the decoded `oneOf` case when no usable `type` + /// is present. Lossy for ambiguous scalars: a base64 BYTES reads back as `.string`, + /// and a whole-number TIMESTAMP reads back as `.int64`. + private static func makeInferredScalar( + from value: Components.Schemas.FieldValueResponse.valuePayload + ) -> FieldValue? { + if case .StringValue(let strVal) = value { + return .string(strVal) + } + if case .Int64Value(let intVal) = value { + return .int64(Int(intVal)) + } + if case .DoubleValue(let dblVal) = value { + return .double(dblVal) + } + if case .BytesValue(let bytesVal) = value { + return .bytes(bytesVal) + } + if case .DateValue(let dateVal) = value { + return .date(Date(timeIntervalSince1970: dateVal / 1_000)) + } + return nil + } + + /// Extract a `Double` from any numeric `oneOf` case (Int64/Double/Date all arrive as numbers). + private static func numericValue( + from value: Components.Schemas.FieldValueResponse.valuePayload + ) -> Double? { + if case .Int64Value(let intVal) = value { + return Double(intVal) + } + if case .DoubleValue(let dblVal) = value { + return dblVal + } + if case .DateValue(let dateVal) = value { + return dateVal + } + return nil + } + + /// Extract a `String` from any string-backed `oneOf` case (String/Bytes both arrive as strings). + private static func stringValue( + from value: Components.Schemas.FieldValueResponse.valuePayload + ) -> String? { + if case .StringValue(let strVal) = value { + return strVal + } + if case .BytesValue(let bytesVal) = value { + return bytesVal + } + return nil + } +} diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift index 8a954a5b..c8cbf355 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift @@ -52,7 +52,9 @@ extension FieldValue { typePayload: Components.Schemas.FieldValueResponse._typePayload?, fieldName: String ) throws(ConversionError) { - if let simpleValue = Self.makeSimpleFieldValue(from: valuePayload, type: typePayload) { + if let simpleValue = + try Self.makeSimpleFieldValue(from: valuePayload, type: typePayload, fieldName: fieldName) + { self = simpleValue } else if let complexValue = try Self.makeComplexFieldValue(from: valuePayload, fieldName: fieldName) @@ -119,30 +121,6 @@ extension FieldValue { self = .asset(asset) } - private static func makeSimpleFieldValue( - from value: Components.Schemas.FieldValueResponse.valuePayload, - type fieldType: Components.Schemas.FieldValueResponse._typePayload? - ) -> FieldValue? { - if case .StringValue(let strVal) = value { - return .string(strVal) - } - if case .Int64Value(let intVal) = value { - return .int64(Int(intVal)) - } - if case .DoubleValue(let dblVal) = value { - return fieldType == .TIMESTAMP - ? .date(Date(timeIntervalSince1970: dblVal / 1_000)) - : .double(dblVal) - } - if case .BytesValue(let bytesVal) = value { - return .bytes(bytesVal) - } - if case .DateValue(let dateVal) = value { - return .date(Date(timeIntervalSince1970: dateVal / 1_000)) - } - return nil - } - private static func makeComplexFieldValue( from value: Components.Schemas.FieldValueResponse.valuePayload, fieldName: String diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift index 42aa7968..02257cd5 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift @@ -34,7 +34,12 @@ internal import MistKitOpenAPI extension Components.Schemas.FieldValueRequest { /// Initialize from MistKit FieldValue for CloudKit API requests. /// - /// CloudKit infers field types from the value structure, so no type field is sent. + /// CloudKit infers a field's type from the value structure, so most values are sent + /// without an explicit `type`. The exceptions are scalars whose JSON form is ambiguous — + /// a `TIMESTAMP`, `BYTES`, or `DOUBLE` is indistinguishable on the wire from an + /// `INT64`/`DOUBLE` number or a `STRING`. For those we tag `type` so CloudKit doesn't + /// infer the wrong type and reject the write with `BAD_REQUEST`. Object/array-shaped + /// values (reference, asset, location, list) are unambiguous and stay untagged. internal init(from fieldValue: FieldValue) { if let scalar = Self.makeScalarRequest(from: fieldValue) { self = scalar @@ -103,13 +108,16 @@ extension Components.Schemas.FieldValueRequest { return Self(value: .Int64Value(Int64(value))) } if case .double(let value) = fieldValue { - return Self(value: .DoubleValue(value)) + // Whole-valued doubles serialize without a fraction and would be read as INT64. + return Self(value: .DoubleValue(value), _type: .DOUBLE) } if case .bytes(let value) = fieldValue { - return Self(value: .BytesValue(value)) + // A base64 string is otherwise indistinguishable from a STRING. + return Self(value: .BytesValue(value), _type: .BYTES) } if case .date(let value) = fieldValue { - return Self(value: .DateValue(value.timeIntervalSince1970 * 1_000)) + // A millisecond number is otherwise inferred as INT64/DOUBLE, not TIMESTAMP. + return Self(value: .DateValue(value.timeIntervalSince1970 * 1_000), _type: .TIMESTAMP) } return nil } diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 3c3b5e26..2010f127 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -834,7 +834,9 @@ public enum Components { } } /// A CloudKit field value for API requests. - /// The type field is optional and used for IN/NOT_IN list filters to specify the list element type. + /// The type field is optional. It is required for the scalar types whose JSON + /// representation is otherwise ambiguous (TIMESTAMP, BYTES, DOUBLE) and for the + /// IN/NOT_IN list filters (the *_LIST types specify the list element type). /// /// /// - Remark: Generated from `#/components/schemas/FieldValueRequest`. @@ -946,10 +948,23 @@ public enum Components { } /// - Remark: Generated from `#/components/schemas/FieldValueRequest/value`. public var value: Components.Schemas.FieldValueRequest.valuePayload - /// Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). + /// Optional CloudKit field type. Sent for scalar values whose JSON form is + /// ambiguous (e.g. "TIMESTAMP", "BYTES", "DOUBLE") so CloudKit does not infer the + /// wrong type, and for IN/NOT_IN list filters (e.g. "INT64_LIST"). + /// /// /// - Remark: Generated from `#/components/schemas/FieldValueRequest/type`. @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { + case STRING = "STRING" + case INT64 = "INT64" + case DOUBLE = "DOUBLE" + case BYTES = "BYTES" + case TIMESTAMP = "TIMESTAMP" + case REFERENCE = "REFERENCE" + case ASSET = "ASSET" + case ASSETID = "ASSETID" + case LOCATION = "LOCATION" + case LIST = "LIST" case STRING_LIST = "STRING_LIST" case INT64_LIST = "INT64_LIST" case DOUBLE_LIST = "DOUBLE_LIST" @@ -958,9 +973,11 @@ public enum Components { case REFERENCE_LIST = "REFERENCE_LIST" case LOCATION_LIST = "LOCATION_LIST" case ASSET_LIST = "ASSET_LIST" - case LIST = "LIST" } - /// Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). + /// Optional CloudKit field type. Sent for scalar values whose JSON form is + /// ambiguous (e.g. "TIMESTAMP", "BYTES", "DOUBLE") so CloudKit does not infer the + /// wrong type, and for IN/NOT_IN list filters (e.g. "INT64_LIST"). + /// /// /// - Remark: Generated from `#/components/schemas/FieldValueRequest/type`. public var _type: Components.Schemas.FieldValueRequest._typePayload? @@ -968,7 +985,7 @@ public enum Components { /// /// - Parameters: /// - value: - /// - _type: Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). + /// - _type: Optional CloudKit field type. Sent for scalar values whose JSON form is public init( value: Components.Schemas.FieldValueRequest.valuePayload, _type: Components.Schemas.FieldValueRequest._typePayload? = nil diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift index bb95d1e0..5cc55e0e 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift @@ -21,6 +21,8 @@ extension FieldValueConversionTests { } else { Issue.record("Expected stringValue") } + // A string is unambiguous on the wire, so no type tag is sent. + #expect(components._type == nil) } @Test("Convert int64 FieldValue to Components.FieldValue") @@ -37,6 +39,8 @@ extension FieldValueConversionTests { } else { Issue.record("Expected int64Value") } + // An integer is the default inference for a whole number, so no type tag is sent. + #expect(components._type == nil) } @Test("Convert double FieldValue to Components.FieldValue") @@ -53,6 +57,8 @@ extension FieldValueConversionTests { } else { Issue.record("Expected doubleValue") } + // A whole-valued double would otherwise be inferred as INT64, so DOUBLE is tagged. + #expect(components._type == .DOUBLE) } @Test("Convert boolean FieldValue to Components.FieldValue") @@ -93,6 +99,8 @@ extension FieldValueConversionTests { } else { Issue.record("Expected bytesValue") } + // A base64 string would otherwise be inferred as STRING, so BYTES is tagged. + #expect(components._type == .BYTES) } @Test("Convert date FieldValue to Components.FieldValue") @@ -110,6 +118,8 @@ extension FieldValueConversionTests { } else { Issue.record("Expected dateValue") } + // Without TIMESTAMP, CloudKit infers INT64 and rejects the write (issue #375). + #expect(components._type == .TIMESTAMP) } } } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift new file mode 100644 index 00000000..92439bf8 --- /dev/null +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift @@ -0,0 +1,136 @@ +internal import Foundation +internal import MistKitOpenAPI +internal import Testing + +@testable import MistKit + +extension FieldValueConversionTests { + /// Read-path coverage for issue #375: an explicit CloudKit `type` must win over the + /// undiscriminated `oneOf` decode (String → Int64 → Double → Bytes → Date), so that + /// ambiguous scalars round-trip instead of reading back as the wrong domain case. + @Suite("Response Type Conversions") + internal struct ResponseTypes { + private static func decode(_ json: String) throws -> FieldValue { + let data = Data(json.utf8) + let response = try JSONDecoder().decode( + Components.Schemas.FieldValueResponse.self, + from: data + ) + return try FieldValue(response, fieldName: "field") + } + + /// Expects decoding `json` to throw a `ConversionError`, with the DEBUG assertion + /// trap suppressed so the throw is observed rather than trapped. + private func expectThrows(_ json: String) { + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect(throws: ConversionError.self) { + _ = try Self.decode(json) + } + } + ) + } + + @Test("Whole-millisecond TIMESTAMP reads back as .date, not .int64") + internal func wholeNumberTimestamp() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + // A whole number decodes as Int64Value, but type TIMESTAMP must recover .date. + let value = try Self.decode(#"{"value": 1493382919000, "type": "TIMESTAMP"}"#) + #expect(value == .date(Date(timeIntervalSince1970: 1_493_382_919))) + } + + @Test("Fractional TIMESTAMP reads back as .date") + internal func fractionalTimestamp() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let value = try Self.decode(#"{"value": 1000500.0, "type": "TIMESTAMP"}"#) + #expect(value == .date(Date(timeIntervalSince1970: 1_000.5))) + } + + @Test("BYTES with type reads back as .bytes, not .string") + internal func bytesWithType() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let value = try Self.decode(#"{"value": "aGVsbG8=", "type": "BYTES"}"#) + #expect(value == .bytes("aGVsbG8=")) + } + + @Test("Whole-valued DOUBLE with type reads back as .double, not .int64") + internal func wholeValuedDouble() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let value = try Self.decode(#"{"value": 5, "type": "DOUBLE"}"#) + #expect(value == .double(5.0)) + } + + @Test("Explicit STRING and INT64 types match their inferred value shape") + internal func explicitStringAndInt() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + #expect(try Self.decode(#"{"value": "hello", "type": "STRING"}"#) == .string("hello")) + #expect(try Self.decode(#"{"value": 42, "type": "INT64"}"#) == .int64(42)) + } + + @Test("A scalar type that contradicts the value's shape throws") + internal func contradictingScalarTypeThrows() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + // A numeric scalar type over a non-numeric value, or a string scalar type over a + // non-string value, is an internally inconsistent response and must not be coerced. + expectThrows(#"{"value": "hello", "type": "TIMESTAMP"}"#) + expectThrows(#"{"value": 42, "type": "BYTES"}"#) + expectThrows(#"{"value": 42, "type": "STRING"}"#) + expectThrows(#"{"value": "x", "type": "DOUBLE"}"#) + expectThrows(#"{"value": "x", "type": "INT64"}"#) + // A numeric scalar type over a complex value is likewise a contradiction. + expectThrows(#"{"value": {"recordName": "r"}, "type": "TIMESTAMP"}"#) + } + + @Test("INT64 over a numeric value defers to inference without truncating") + internal func int64DefersToInference() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + // 3.5 satisfies the numeric category, so INT64 validates then defers to inference, + // preserving .double rather than truncating to an integer. + #expect(try Self.decode(#"{"value": 3.5, "type": "INT64"}"#) == .double(3.5)) + } + + @Test("A complex declared type over a scalar value is left to inference, not validated") + internal func complexTypeContradictionStaysLenient() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + // Only scalar type tags are strictly validated; complex/list types defer to the + // value's self-describing structure, so this reads back from the value shape. + #expect(try Self.decode(#"{"value": 42, "type": "REFERENCE"}"#) == .int64(42)) + } + + @Test("Without a type, scalars fall back to first-match-wins inference") + internal func inferenceWithoutType() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + #expect(try Self.decode(#"{"value": "plain"}"#) == .string("plain")) + #expect(try Self.decode(#"{"value": 42}"#) == .int64(42)) + #expect(try Self.decode(#"{"value": 3.5}"#) == .double(3.5)) + } + } +} diff --git a/openapi.yaml b/openapi.yaml index 666649a1..d4bf6829 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1009,7 +1009,9 @@ components: type: object description: | A CloudKit field value for API requests. - The type field is optional and used for IN/NOT_IN list filters to specify the list element type. + The type field is optional. It is required for the scalar types whose JSON + representation is otherwise ambiguous (TIMESTAMP, BYTES, DOUBLE) and for the + IN/NOT_IN list filters (the *_LIST types specify the list element type). properties: value: oneOf: @@ -1024,8 +1026,11 @@ components: - $ref: '#/components/schemas/ListValue' type: type: string - enum: [STRING_LIST, INT64_LIST, DOUBLE_LIST, BYTES_LIST, TIMESTAMP_LIST, REFERENCE_LIST, LOCATION_LIST, ASSET_LIST, LIST] - description: Optional CloudKit list type for IN/NOT_IN filters (e.g. "INT64_LIST"). + enum: [STRING, INT64, DOUBLE, BYTES, TIMESTAMP, REFERENCE, ASSET, ASSETID, LOCATION, LIST, STRING_LIST, INT64_LIST, DOUBLE_LIST, BYTES_LIST, TIMESTAMP_LIST, REFERENCE_LIST, LOCATION_LIST, ASSET_LIST] + description: | + Optional CloudKit field type. Sent for scalar values whose JSON form is + ambiguous (e.g. "TIMESTAMP", "BYTES", "DOUBLE") so CloudKit does not infer the + wrong type, and for IN/NOT_IN list filters (e.g. "INT64_LIST"). required: - value From ea3089f955bb7942ce8b585d00a1810f93931c44 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 22 May 2026 17:10:54 -0400 Subject: [PATCH 02/10] Point MISTKIT_BRANCH at 375-fieldvaluerequest-timestamp Exercise the MistKit TIMESTAMP write/read fix (brightdigit/MistKit#375) in CI before it lands on v1.0.0-beta.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- Examples/CelestraCloud/.github/workflows/CelestraCloud.yml | 2 +- Examples/CelestraCloud/.github/workflows/update-feeds.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml index a878600d..d2d0952e 100644 --- a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml +++ b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml @@ -21,7 +21,7 @@ concurrency: env: PACKAGE_NAME: CelestraCloud - MISTKIT_BRANCH: v1.0.0-beta.2 + MISTKIT_BRANCH: 375-fieldvaluerequest-timestamp jobs: configure: diff --git a/Examples/CelestraCloud/.github/workflows/update-feeds.yml b/Examples/CelestraCloud/.github/workflows/update-feeds.yml index 8a44920b..70fe1eee 100644 --- a/Examples/CelestraCloud/.github/workflows/update-feeds.yml +++ b/Examples/CelestraCloud/.github/workflows/update-feeds.yml @@ -49,7 +49,7 @@ env: CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }} CLOUDKIT_ENVIRONMENT: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && 'development' || github.event.inputs.environment || 'production' }} CLOUDKIT_PRIVATE_KEY_PATH: /tmp/cloudkit_key.pem - MISTKIT_BRANCH: v1.0.0-beta.2 + MISTKIT_BRANCH: 375-fieldvaluerequest-timestamp jobs: # Determine which tier to run based on schedule or manual input From ebcf0345c3d69603f965555676bb815524e7c218 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 22 May 2026 17:11:02 -0400 Subject: [PATCH 03/10] git subrepo push Examples/CelestraCloud subrepo: subdir: "Examples/CelestraCloud" merged: "243f8fd" upstream: origin: "git@github.com:brightdigit/CelestraCloud.git" branch: "mistkit" commit: "243f8fd" git-subrepo: version: "0.4.9" origin: "https://github.com/Homebrew/brew" commit: "6f293daa9f" --- Examples/CelestraCloud/.gitrepo | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/CelestraCloud/.gitrepo b/Examples/CelestraCloud/.gitrepo index 55e9492c..a7c11f1c 100644 --- a/Examples/CelestraCloud/.gitrepo +++ b/Examples/CelestraCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/CelestraCloud.git branch = mistkit - commit = dbdba1a3748da6f3adefce90c06083108bce9f36 - parent = 9600f7882f53b51825bc8f2d6a163f4d4c97d10e + commit = 243f8fdc76c52651e69fe41c42782f91a3846cd0 + parent = ea3089f955bb7942ce8b585d00a1810f93931c44 method = merge cmdver = 0.4.9 From 7be5ad2334396225471c5afd10f025ee3f3ee2a7 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 23 May 2026 10:22:06 -0400 Subject: [PATCH 04/10] setup-mistkit: pin MistKit by resolved commit SHA, not branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swift-build@v1 keys its build cache on `swift package dump-package`. Pinning the injected MistKit dependency by `branch:` produces identical dumped output across commits on the same branch, so a new MistKit commit yields a stale cache hit and is never rebuilt. Resolve the branch HEAD via `git ls-remote` and pin by `revision:` so the dumped package — and thus the cache key — changes with every MistKit commit. Falls back to a `branch:` pin if the ref can't be resolved. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/setup-mistkit/action.yml | 32 +++++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup-mistkit/action.yml b/.github/actions/setup-mistkit/action.yml index 70a23028..6cfbda11 100644 --- a/.github/actions/setup-mistkit/action.yml +++ b/.github/actions/setup-mistkit/action.yml @@ -1,5 +1,5 @@ name: Setup MistKit -description: Replaces the local MistKit path dependency with a remote branch reference +description: Replaces the local MistKit path dependency with a remote reference, pinned to the branch's current commit inputs: branch: @@ -8,19 +8,43 @@ inputs: runs: using: composite steps: + # Resolve the branch to its current HEAD commit and pin the dependency by + # `revision:` rather than `branch:`. This makes the dependency content-addressed, + # so `swift package dump-package` (which swift-build@v1 hashes for its cache key) + # changes whenever the MistKit branch advances — otherwise a new MistKit commit on + # the same branch yields a stale cache hit and is never rebuilt. Falls back to a + # `branch:` pin if the ref can't be resolved (e.g. offline). - name: Update Package.swift (Unix) if: inputs.branch != '' && runner.os != 'Windows' shell: bash run: | + BRANCH='${{ inputs.branch }}' + REF=$(git ls-remote https://github.com/brightdigit/MistKit.git "$BRANCH" | head -n1 | cut -f1) + if [ -n "$REF" ]; then + REQ='revision: "'"$REF"'"' + echo "Pinning MistKit to $BRANCH @ $REF" + else + REQ='branch: "'"$BRANCH"'"' + echo "Could not resolve $BRANCH to a commit; pinning by branch" + fi if [ "$RUNNER_OS" = "macOS" ]; then - sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift + sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", '"$REQ"')|g' Package.swift else - sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift + sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", '"$REQ"')|g' Package.swift fi rm -f Package.resolved - name: Update Package.swift (Windows) if: inputs.branch != '' && runner.os == 'Windows' shell: pwsh run: | - (Get-Content Package.swift) -replace '\.package\(name: "MistKit", path: "\.\./\.\."\)', ".package(url: `"https://github.com/brightdigit/MistKit.git`", branch: `"${{ inputs.branch }}`")" | Set-Content Package.swift + $branch = '${{ inputs.branch }}' + $ref = (git ls-remote https://github.com/brightdigit/MistKit.git $branch | Select-Object -First 1) -split "`t" | Select-Object -First 1 + if ($ref) { + $req = "revision: `"$ref`"" + Write-Host "Pinning MistKit to $branch @ $ref" + } else { + $req = "branch: `"$branch`"" + Write-Host "Could not resolve $branch to a commit; pinning by branch" + } + (Get-Content Package.swift) -replace '\.package\(name: "MistKit", path: "\.\./\.\."\)', ".package(url: `"https://github.com/brightdigit/MistKit.git`", $req)" | Set-Content Package.swift Remove-Item -Path Package.resolved -Force -ErrorAction SilentlyContinue From 30281504a3878d56d8ea9e4956b7ef0c7fa2455e Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 23 May 2026 10:22:07 -0400 Subject: [PATCH 05/10] CelestraCloud: include MistKit SHA in update-feeds binary cache key The compiled-binary cache keyed only on CelestraCloud's own sources, so a new MistKit commit on the pinned branch produced a stale cache hit and the integration test ran against the old binary. Resolve MISTKIT_BRANCH to its HEAD commit and fold it into the cache key so MistKit changes force a rebuild. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.github/workflows/update-feeds.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Examples/CelestraCloud/.github/workflows/update-feeds.yml b/Examples/CelestraCloud/.github/workflows/update-feeds.yml index 70fe1eee..9b464292 100644 --- a/Examples/CelestraCloud/.github/workflows/update-feeds.yml +++ b/Examples/CelestraCloud/.github/workflows/update-feeds.yml @@ -141,12 +141,23 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # Resolve MISTKIT_BRANCH to its current HEAD commit so the cache key below + # changes whenever MistKit is updated. Without this the binary cache keys only + # on CelestraCloud's own sources, so a new MistKit commit on the same branch + # produces a stale cache hit and the fix under test is never rebuilt. + - name: Resolve MistKit branch SHA + id: mistkit-sha + run: | + SHA=$(git ls-remote https://github.com/brightdigit/MistKit.git "$MISTKIT_BRANCH" | head -n1 | cut -f1) + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Resolved MISTKIT_BRANCH=$MISTKIT_BRANCH to ${SHA:-}" + - name: Cache compiled binary id: cache-binary uses: actions/cache@v4 with: path: .build/release/celestra-cloud - key: celestra-cloud-${{ runner.os }}-${{ hashFiles('Sources/**/*.swift', 'Package.swift') }}-${{ github.event.inputs.force_rebuild || 'false' }} + key: celestra-cloud-${{ runner.os }}-${{ hashFiles('Sources/**/*.swift', 'Package.swift') }}-${{ steps.mistkit-sha.outputs.sha }}-${{ github.event.inputs.force_rebuild || 'false' }} - name: Setup MistKit if: steps.cache-binary.outputs.cache-hit != 'true' From 7d989019ea1508fc990bad2f0dd30e5be4087eb0 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 23 May 2026 10:22:07 -0400 Subject: [PATCH 06/10] BushelCloud: count batch successes via RecordResult.success case modifyRecords returns [RecordResult]; RecordResult has no `record` property, so `results.filter { $0.record != nil }` failed to compile against current MistKit. Count `.success` cases instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BushelCloudKit/CloudKit/BushelCloudKitService.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift index 7c12482d..11ca2d42 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift @@ -261,7 +261,10 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol } } - let batchSucceeded = results.filter { $0.record != nil }.count + let batchSucceeded = results.filter { result in + if case .success = result { return true } + return false + }.count let batchFailed = results.count - batchSucceeded if batchFailed > 0 { From 114dac232c8f429e17fc076053759a58f5cffac5 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 23 May 2026 10:22:14 -0400 Subject: [PATCH 07/10] git subrepo push Examples/CelestraCloud subrepo: subdir: "Examples/CelestraCloud" merged: "8657d77" upstream: origin: "git@github.com:brightdigit/CelestraCloud.git" branch: "mistkit" commit: "8657d77" git-subrepo: version: "0.4.9" origin: "https://github.com/Homebrew/brew" commit: "6f293daa9f" --- Examples/CelestraCloud/.gitrepo | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/CelestraCloud/.gitrepo b/Examples/CelestraCloud/.gitrepo index a7c11f1c..468c572e 100644 --- a/Examples/CelestraCloud/.gitrepo +++ b/Examples/CelestraCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/CelestraCloud.git branch = mistkit - commit = 243f8fdc76c52651e69fe41c42782f91a3846cd0 - parent = ea3089f955bb7942ce8b585d00a1810f93931c44 + commit = 8657d77b436c2d1c4f9c82f6ca60a4debe958b0b + parent = 7d989019ea1508fc990bad2f0dd30e5be4087eb0 method = merge cmdver = 0.4.9 From a566dfa1b0a19877fdd0ad8aaafa4ba4d178aebe Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 23 May 2026 10:23:31 -0400 Subject: [PATCH 08/10] git subrepo commit (merge) Examples/BushelCloud subrepo: subdir: "Examples/BushelCloud" merged: "572d1cd" upstream: origin: "git@github.com:brightdigit/BushelCloud.git" branch: "mistkit" commit: "fdbe52a" git-subrepo: version: "0.4.9" origin: "https://github.com/Homebrew/brew" commit: "6f293daa9f" --- Examples/BushelCloud/.gitrepo | 2 +- .../CloudKit/BushelCloudKitService.swift | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Examples/BushelCloud/.gitrepo b/Examples/BushelCloud/.gitrepo index 2420a31b..86237e24 100644 --- a/Examples/BushelCloud/.gitrepo +++ b/Examples/BushelCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/BushelCloud.git branch = mistkit - commit = 7f026acd1b3f926a81ee6a308f0ad4f43d95e16b + commit = fdbe52ad61da1eac3e824b0977426a1b70f53498 parent = b993bb931ea3d0698fc22428dc02851f26b36701 method = merge cmdver = 0.4.9 diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift index 11ca2d42..c8ca3225 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift @@ -233,7 +233,10 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol "Calling MistKit service.modifyRecords() with \(batch.count) RecordOperation objects" ) - let results = try await service.modifyRecords( + // Annotate the element type explicitly: the Linux Swift compiler otherwise + // infers `[RecordInfo]` for this call, breaking the .success/.failure switch + // below (see brightdigit/BushelCloud CI on Ubuntu). + let results: [RecordResult] = try await service.modifyRecords( batch, database: .public(.prefers(.serverToServer)) ) @@ -243,15 +246,19 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol ) // Track results based on classification + var batchSucceeded = 0 + var batchFailed = 0 for result in results { switch result { case .failure(let error): totalFailed += 1 + batchFailed += 1 failedRecordNames.append(error.recordName) Self.logger.debug( "Error: recordName=\(error.recordName), code=\(error.serverErrorCode.rawValue)" ) case .success(let record): + batchSucceeded += 1 // Classify as create or update based on pre-fetch if classification.creates.contains(record.recordName) { totalCreated += 1 @@ -261,12 +268,6 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol } } - let batchSucceeded = results.filter { result in - if case .success = result { return true } - return false - }.count - let batchFailed = results.count - batchSucceeded - if batchFailed > 0 { print(" ⚠️ \(batchFailed) operations failed (see verbose logs for details)") print(" ✓ \(batchSucceeded) records confirmed") From 1eb639ad33a89c38df2504056aa78a3a83e8bccb Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 23 May 2026 10:37:09 -0400 Subject: [PATCH 09/10] Round TIMESTAMP/location date values to whole milliseconds (#375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tagging .date with type TIMESTAMP was necessary but not sufficient: Date carries sub-millisecond precision, so `timeIntervalSince1970 * 1000` produced a fractional value (e.g. 1747999812347.89) on the wire. CloudKit requires an integer millisecond TIMESTAMP and rejects a fractional one with BAD_REQUEST "expected type TIMESTAMP" — the exact failure CelestraCloud's integration test still hit after the type tag fix. Round the millisecond value to a whole number for both the scalar .date request and the LocationValue timestamp. Whole milliseconds (~1.7e12) are exactly representable as Double, and JSONEncoder renders them without a fraction. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...Components.Schemas.FieldValueRequest.swift | 11 +++++++--- ...FieldValueConversionTests+BasicTypes.swift | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift index 02257cd5..ea7ff90e 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift @@ -58,7 +58,7 @@ extension Components.Schemas.FieldValueRequest { altitude: location.altitude, speed: location.speed, course: location.course, - timestamp: location.timestamp.map { $0.timeIntervalSince1970 * 1_000 } + timestamp: location.timestamp.map { ($0.timeIntervalSince1970 * 1_000).rounded() } ) self.init(value: .LocationValue(locationValue)) } @@ -116,8 +116,13 @@ extension Components.Schemas.FieldValueRequest { return Self(value: .BytesValue(value), _type: .BYTES) } if case .date(let value) = fieldValue { - // A millisecond number is otherwise inferred as INT64/DOUBLE, not TIMESTAMP. - return Self(value: .DateValue(value.timeIntervalSince1970 * 1_000), _type: .TIMESTAMP) + // Tag TIMESTAMP (else inferred as INT64/DOUBLE) and round to whole milliseconds: + // CloudKit rejects a fractional TIMESTAMP value (e.g. 1747999812347.89) with + // BAD_REQUEST "expected type TIMESTAMP", and Date carries sub-millisecond precision. + return Self( + value: .DateValue((value.timeIntervalSince1970 * 1_000).rounded()), + _type: .TIMESTAMP + ) } return nil } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift index 5cc55e0e..a303ca41 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift @@ -121,5 +121,25 @@ extension FieldValueConversionTests { // Without TIMESTAMP, CloudKit infers INT64 and rejects the write (issue #375). #expect(components._type == .TIMESTAMP) } + + @Test("Convert fractional date rounds to whole milliseconds") + internal func convertFractionalDate() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + // Date carries sub-millisecond precision; CloudKit rejects a fractional TIMESTAMP + // value with BAD_REQUEST, so the millisecond value must be a whole number. + let date = Date(timeIntervalSince1970: 1_747_999_812.3478923) + let components = Components.Schemas.FieldValueRequest(from: .date(date)) + + if case .DateValue(let value) = components.value { + #expect(value == 1_747_999_812_348) + #expect(value == value.rounded()) + } else { + Issue.record("Expected dateValue") + } + #expect(components._type == .TIMESTAMP) + } } } From 7b18ebf25a7d03e7f47bebc4783b7c78eef2dec2 Mon Sep 17 00:00:00 2001 From: leogdion Date: Sat, 23 May 2026 13:07:38 -0400 Subject: [PATCH 10/10] Address PR #377 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `_typePayload.rawValue` for `declaredType` strings in scalar response conversion instead of duplicating the enum spellings as literals. - Drop the bare `LIST` value from `FieldValueRequest.type` in `openapi.yaml` (kept on the response side for lenient decoding) and regenerate `MistKitOpenAPI`. `FilterBuilder` no longer emits the undocumented bare tag — nested-list fallback returns `nil` and lets CloudKit reject if reached. - Document the rounding rationale on `LocationValue.timestamp` to match the existing scalar `.date` comment (same fractional-TIMESTAMP rejection on the wire). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FieldValue+Components+Scalar.swift | 15 ++++++++++----- .../Queries/FilterBuilder/FilterBuilder.swift | 4 +++- .../Components.Schemas.FieldValueRequest.swift | 2 ++ Sources/MistKitOpenAPI/Types.swift | 1 - openapi.yaml | 2 +- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift index 2809dde3..ad02e46f 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift @@ -64,19 +64,24 @@ extension FieldValue { type fieldType: Components.Schemas.FieldValueResponse._typePayload?, fieldName: String ) throws(ConversionError) -> FieldValue? { + guard let fieldType else { return nil } switch fieldType { case .TIMESTAMP: - let millis = try requireNumeric(value, fieldName: fieldName, declaredType: "TIMESTAMP") + let millis = try requireNumeric(value, fieldName: fieldName, declaredType: fieldType.rawValue) return .date(Date(timeIntervalSince1970: millis / 1_000)) case .DOUBLE: - return .double(try requireNumeric(value, fieldName: fieldName, declaredType: "DOUBLE")) + return .double( + try requireNumeric(value, fieldName: fieldName, declaredType: fieldType.rawValue) + ) case .INT64: - _ = try requireNumeric(value, fieldName: fieldName, declaredType: "INT64") + _ = try requireNumeric(value, fieldName: fieldName, declaredType: fieldType.rawValue) return nil case .BYTES: - return .bytes(try requireString(value, fieldName: fieldName, declaredType: "BYTES")) + return .bytes( + try requireString(value, fieldName: fieldName, declaredType: fieldType.rawValue) + ) case .STRING: - _ = try requireString(value, fieldName: fieldName, declaredType: "STRING") + _ = try requireString(value, fieldName: fieldName, declaredType: fieldType.rawValue) return nil default: return nil diff --git a/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift index 92795482..bd4c03df 100644 --- a/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift +++ b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift @@ -205,6 +205,8 @@ internal struct FilterBuilder { if case .asset = first { return .ASSET_LIST } - return .LIST + // Nested lists aren't valid in IN/NOT_IN; omit the type and let CloudKit reject + // rather than emit an undocumented bare "LIST" tag. + return nil } } diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift index ea7ff90e..037de292 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift @@ -58,6 +58,8 @@ extension Components.Schemas.FieldValueRequest { altitude: location.altitude, speed: location.speed, course: location.course, + // CloudKit rejects a fractional TIMESTAMP on LocationValue.timestamp with BAD_REQUEST, + // same wire-type constraint as the scalar .date case; Date carries sub-millisecond precision. timestamp: location.timestamp.map { ($0.timeIntervalSince1970 * 1_000).rounded() } ) self.init(value: .LocationValue(locationValue)) diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 2010f127..f613e955 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -964,7 +964,6 @@ public enum Components { case ASSET = "ASSET" case ASSETID = "ASSETID" case LOCATION = "LOCATION" - case LIST = "LIST" case STRING_LIST = "STRING_LIST" case INT64_LIST = "INT64_LIST" case DOUBLE_LIST = "DOUBLE_LIST" diff --git a/openapi.yaml b/openapi.yaml index d4bf6829..08516f64 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1026,7 +1026,7 @@ components: - $ref: '#/components/schemas/ListValue' type: type: string - enum: [STRING, INT64, DOUBLE, BYTES, TIMESTAMP, REFERENCE, ASSET, ASSETID, LOCATION, LIST, STRING_LIST, INT64_LIST, DOUBLE_LIST, BYTES_LIST, TIMESTAMP_LIST, REFERENCE_LIST, LOCATION_LIST, ASSET_LIST] + enum: [STRING, INT64, DOUBLE, BYTES, TIMESTAMP, REFERENCE, ASSET, ASSETID, LOCATION, STRING_LIST, INT64_LIST, DOUBLE_LIST, BYTES_LIST, TIMESTAMP_LIST, REFERENCE_LIST, LOCATION_LIST, ASSET_LIST] description: | Optional CloudKit field type. Sent for scalar values whose JSON form is ambiguous (e.g. "TIMESTAMP", "BYTES", "DOUBLE") so CloudKit does not infer the