Tag and validate ambiguous FieldValue scalar types (#375)#377
Conversation
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) <noreply@anthropic.com>
Exercise the MistKit TIMESTAMP write/read fix (#375) in CI before it lands on v1.0.0-beta.2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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"
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code Review — PR #377: Tag and validate ambiguous FieldValue scalar typesThis is a well-motivated and well-executed fix. The root cause is clearly understood, the write-path and read-path are both addressed, and the new Must fix before merge
Design discussion
Minor observations
Test coverageThe new
The PR #331Trivial and correct — copyright year updates only, no functional changes. Overall: Approve once the 🤖 Generated with Claude Code |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## v1.0.0-beta.2 #377 +/- ##
================================================
Coverage ? 68.56%
================================================
Files ? 133
Lines ? 3025
Branches ? 0
================================================
Hits ? 2074
Misses ? 951
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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"
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"
Code Review — PR #377: Tag and validate ambiguous FieldValue scalar typesThe core fix is correct and well-motivated. The three-layer architecture (typed → inferred → complex) is clean, and the request-side tagging logic is straightforward. Here are my findings, ranked by severity. 🔴 Merge blocker — CelestraCloud branch pinFiles: Both files pin 🟡 Dead code —
|
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) <noreply@anthropic.com>
Code ReviewThis is a well-targeted, clearly-reasoned fix. The root-cause diagnosis is solid, the implementation is clean, and the test coverage is good. A few observations below. Correctness / Design
In
CI / InfrastructureCelestraCloud
The fallback to Code Quality
Separating
Minor style point: a
Moving Tests
The round-trip matrix (whole-millisecond TIMESTAMP, BYTES, whole-valued DOUBLE, explicit STRING/INT64 passthrough, contradiction throws, INT64 non-truncation, complex-type leniency, inferred fallback) covers the key cases. The use of Test fixture JSON is literal and readable — using raw string literals ( SummaryThe core fix is correct and well-tested. Main pre-merge items:
479 tests passing, format and lint clean — looks ready to land once the branch pin is reverted. |
| 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 |
There was a problem hiding this comment.
why are we using strings instead of enumeration or rawValue of enum
| speed: location.speed, | ||
| course: location.course, | ||
| timestamp: location.timestamp.map { $0.timeIntervalSince1970 * 1_000 } | ||
| timestamp: location.timestamp.map { ($0.timeIntervalSince1970 * 1_000).rounded() } |
There was a problem hiding this comment.
why does it need to be rounded?
| // 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()), |
There was a problem hiding this comment.
why does it need to be rounded?
| case ASSET = "ASSET" | ||
| case ASSETID = "ASSETID" | ||
| case LOCATION = "LOCATION" | ||
| case LIST = "LIST" |
There was a problem hiding this comment.
Is LIST an actual value or should it be a "*_LIST"
Code Review — PR #375: Tag and validate ambiguous FieldValue scalar typesOverall this is a well-structured fix for a real CloudKit API bug. The problem statement is clear, the two-sided fix (write path + read path) is sound, and the test suite covers the key round-trip scenarios. A few items worth addressing before merge: Correctness / potential bugs1.
The current approach is defensible (CloudKit is the source of truth, don't lose data), but a future reader will find this surprising without an explicit callout. 2. Truncated docstring in generated /// - _type: Optional CloudKit field type. Sent for scalar values whose JSON form is
public init(The sentence is cut off. Since 3.
Design choices worth a comment4.
Correctness (positive notes)
CI / pre-merge reminderThe PR description notes that Summary: The core fix is correct and well-tested. Three actionable items: (1) complete the truncated docstring in |
Fixes #375. Follow-up tracked in #376.
Problem
CloudKit infers a field's type from its value structure. A
.datewas serialized as a bare millisecond number, so CloudKit read it asINT64and rejected it againstTIMESTAMPschema fields withBAD_REQUEST— blocking every CelestraCloud record write that includes a timestamp.Write path
FieldValueRequest.typeinopenapi.yamland regeneratedMistKitOpenAPI.makeScalarRequestnow emitstypefor the three ambiguous scalars:.date→TIMESTAMP,.bytes→BYTES,.double→DOUBLE. Object/array-shaped values andSTRING/INT64stay untagged (unambiguous).Read path
The generated
valueoneOfis undiscriminated (first-match-wins:String → Int64 → Double → Bytes → Date), so a whole-millisecondTIMESTAMPdecoded asInt64Valueand read back as.int64even withtypepresent. Response conversion (FieldValue+Components+Scalar.swift) now honors an explicit scalartypeover the decoded case — recoveringTIMESTAMP/DOUBLEfrom any numeric case andBYTESfrom any string case.Fail-loud on contradiction
When a scalar
typecontradicts the value's category (a numeric tag over a non-number, or a string tag over a non-string), conversion throws the newConversionError.typeValueMismatchinstead of silently coercing — matching the existingunmappableFieldValuephilosophy. Strict validation is scoped to scalar tags; complex/list tags stay lenient (follow-up: #376).Tests
FieldValueConversionTests+BasicTypes.swift.FieldValueConversionTests+ResponseTypes.swiftcovering the read-path round-trips (incl. the whole-millisecondTIMESTAMP→.datecase that was broken), the contradiction throws, and the lenient-complex-type boundary.479 tests pass; build, swift-format, and swiftlint all clean.
🤖 Generated with Claude Code