Add operation classification and batch sync result tracking#296
Conversation
CloudKit's /records/modify endpoint does not indicate whether each
operation produced a newly created record or an update, which makes
detailed sync summaries ("Created: 5, Updated: 20, Failed: 0")
impossible to derive from the response alone. This adds the
pre-fetch + classify pattern to MistKit core so consumers no longer
have to reimplement it themselves (per BushelCloud).
Adds three additive types/methods, all opt-in:
- OperationClassification — partitions proposed record names into
creates vs updates by comparing against existing names; ships with
initializers for [String], [RecordOperation], and direct construction.
- BatchSyncResult — categorized response with created/updated/failed/
unclassified RecordInfo arrays plus count properties.
- CloudKitService.fetchExistingRecordNames(recordType:) and
modifyRecords(_:classification:atomic:) overload that pairs the
pre-fetch with a tracked modify call.
The existing modifyRecords(_:atomic:) signature is unchanged.
|
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 |
| /// - Throws: `CloudKitError` if the underlying query fails. | ||
| public func fetchExistingRecordNames( | ||
| recordType: String | ||
| ) async throws(CloudKitError) -> Set<String> { |
There was a problem hiding this comment.
shouldn't we have an optional limit argument?
There was a problem hiding this comment.
Good call — added limit: Int? = nil to fetchExistingRecordNames(recordType:limit:) in 73cd1be. Defaults to CloudKit's per-request max so existing call sites don't change.
Generated by Claude Code
| // RecordManaging-conforming overload (which has untyped throws). | ||
| let records = try await queryRecords( | ||
| recordType: recordType, | ||
| limit: 200 |
There was a problem hiding this comment.
Isn't 200 a constant somewhere? perhaps MistKit?
There was a problem hiding this comment.
Searched and there wasn't one — 200 was an inline literal in CloudKitService+Operations.swift:104, +SyncOperations.swift:86, and +RecordManaging.swift:52. Added internal static let maxRecordsPerRequest: Int = 200 on CloudKitService in 73cd1be and reference it from fetchExistingRecordNames. Happy to follow up with a separate PR replacing the other three call sites if you'd like — kept the scope tight here.
Generated by Claude Code
| internal static func makeErrorRecord(name: String = "Unknown") -> RecordInfo { | ||
| RecordInfo( | ||
| recordName: name, | ||
| recordType: "Unknown", | ||
| recordChangeTag: nil, | ||
| fields: [:] | ||
| ) | ||
| } |
There was a problem hiding this comment.
this seems like a bad pattern. Is there an alternative?
There was a problem hiding this comment.
Agreed — the helper was reaching past the public API to forge an error record by hardcoding the "Unknown" sentinel.
Fixed in 73cd1be by routing through the same decoding path production uses: RecordInfo(from: Components.Schemas.RecordResponse()). That's the existing pattern in RecordInfoTests.recordInfoWithUnknownRecord (Tests/MistKitTests/Core/RecordInfoTests.swift:12) and produces the same shape CloudKit yields for failures, so the test no longer encodes private knowledge of the sentinel string.
The "errors win over classification" test was also updated to read the error record's recordName dynamically instead of asserting on "Unknown".
Longer-term, if you'd like to retire the sentinel entirely (e.g. give RecordInfo an explicit error: ErrorPayload? instead of the recordType == "Unknown" check), happy to file that as a follow-up — felt out of scope for this PR.
Generated by Claude Code
- Add optional `limit:` argument to `fetchExistingRecordNames(recordType:)` so callers can scope the prefetch when they know they need fewer than 200 names; defaults to CloudKit's per-request maximum. - Introduce `CloudKitService.maxRecordsPerRequest` (= 200) and use it instead of the bare literal so the magic number lives in one place. - Build the error `RecordInfo` in `BatchSyncResultTests` via the same `RecordInfo(from: Components.Schemas.RecordResponse())` decoding path used by `RecordInfoTests`, instead of hardcoding the "Unknown" sentinel string in the test. The "errors win over classification" test now reads the record's actual name dynamically.
Code Review (Draft)OverviewAdds Code QualityPositive:
Issues:
Test CoverageUnit tests for
SummaryGood feature addition. Main pre-merge items: document the TOCTOU race and address the 200-record silent-truncation footgun. The overload comment and constant visibility are lower priority. |
Code Review — PR #296: Add operation classification and batch sync result trackingSummary: Well-designed feature that addresses a real CloudKit API gap. The separation into Positives
Concerns1. TOCTOU race condition is underdocumented The fetch → classify → modify pattern has an inherent race: records can be created or deleted between Suggest adding a note in the
2. The public func fetchExistingRecordNames(
recordType: String,
limit: Int? = nil
) async throws(CloudKitError) -> Set<String>The doc comment states "1-200" but nothing enforces this. A caller passing 3. No pagination path for record types with >200 records The 200-record cap is documented and the PR acknowledges it, but there's no async sequence or continuation hook. This makes Acceptable as a v1, but worth adding a 4. public var succeededCount: Int {
created.count + updated.count + unclassified.count
}
5. Missing test for The tests cover Minor / nits
Verdict: Approve with the request to address the undocumented TOCTOU race (#1) and the unvalidated |
Code Review: Add operation classification and batch sync result trackingOverviewThis PR solves a real CloudKit limitation cleanly: the IssuesScalability gap in
|
Code Review: Add operation classification and batch sync result trackingOverviewThis PR correctly solves a real CloudKit API gap — the Issues🔴 Silent data correctness risk: pagination gap in The 200-record cap is documented in the doc comment, but returning a Options:
🟡 Race condition is undocumented Between
🟡 The manual initializer is public with no validation that
🟡 Overload disambiguation comment in // Pass `limit:` explicitly so overload resolution picks the typed-throws
// variant of `queryRecords` rather than the 1-param RecordManaging-
// conforming overload (which has untyped throws).This is a fragile dependency on argument-label disambiguation. If Minor
Test CoverageTest coverage is comprehensive and well-structured:
Missing:
Code QualityThe code is well-structured, follows project conventions ( |
Code Review — PR #296: Operation Classification & Batch Sync Result TrackingOverviewThis PR adds a well-reasoned workaround for CloudKit's missing create/update discrimination in Issues🔴 Pagination silent truncation (
|
Code Review — PR #296: Add operation classification and batch sync result trackingOverviewWell-designed addition that implements the documented CloudKit workaround for tracking creates vs. updates in batch modify responses. Types are Issues1. Silent 200-record cap in
|
Code Review — PR #296: Add operation classification and batch sync result trackingOverviewThis PR implements the CloudKit pre-fetch + classify workaround pattern with clean types and comprehensive tests. The design is sound and the implementation is correct. A few issues worth addressing before merge. What's Good
Issues & Suggestions1. TOCTOU race condition — document it (correctness) Between 2. The doc comment says "you must paginate at the call site or use a custom query" but callers have no built-in way to know if they hit the cap. Consider returning the full count or adding a sanity check: // Option A: warn in the doc that returned count == limit means truncated
// Option B: expose a paginating overload that collects all pages
// Option C: expose maxRecordsPerRequest as public so callers can check themselvesAt minimum, making 3.
4.
5. The convenience initializer chains correctly but could be simplified further In let proposedNames = operations.compactMap(\.recordName)
self.init(proposedRecordNames: proposedNames, existingRecordNames: existingRecordNames)This is correct but could be written as a single expression: self.init(
proposedRecordNames: operations.compactMap(\.recordName),
existingRecordNames: existingRecordNames
)Minor nit — take or leave. Test CoverageStrong. The five test suites cover the core scenarios well. One missing case worth adding: what happens when the same record name appears in both Summary of Action Items
Verdict: Approve with recommendations. The core implementation is correct and well-tested. Items 1–3 above are worth addressing before merge; the rest are polish. |
Code Review: PR #296 — Operation Classification & Batch Sync Result TrackingOverviewThis PR cleanly solves a real CloudKit limitation: Issues🔴 TOCTOU Race Condition (silent misclassification)
This may be acceptable in some use cases, but the docs should warn about it explicitly — especially since this API is designed for sync auditing where misclassification is exactly the failure mode users care about. A note in both the extension doc comment and 🔴 Silent Truncation at 200 Records
Suggestion: name the failure mode directly — "If the record type has more than 200 existing records and you do not paginate, some updates will be misclassified as creates." A Also consider: should 🟡
|
| Logic correctness | ✅ Sound partitioning, correct priority order (errors > creates > updates > unclassified) |
| Concurrency safety | ✅ All types are Sendable; async throws used correctly |
| Documentation | |
| Tests | |
| Style / conventions | ✅ Matches project patterns; @available annotation consistent with CloudKitService |
The core implementation is solid. The main ask is stronger documentation of the failure modes that matter most for sync auditing: TOCTOU races and silent truncation at 200 records.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## v1.0.0-beta.1 #296 +/- ##
==================================================
+ Coverage 25.66% 66.03% +40.36%
==================================================
Files 95 480 +385
Lines 8240 13708 +5468
==================================================
+ Hits 2115 9052 +6937
+ Misses 6125 4656 -1469
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:
|
Code Review — PR #296: Operation Classification and Batch Sync Result TrackingOverviewThis PR adds a clean workaround for CloudKit's missing create-vs-update signal in Issues1. Unnecessary
|
Code Review — PR #296: Operation Classification & Batch Sync Result TrackingOverviewSolid addition that solves a real CloudKit API gap. The three-file split (types + service extension) is clean, the Issues
|
Summary
This PR introduces two new public types and a service extension to enable tracking of create vs. update operations in CloudKit batch modify requests. CloudKit's
/records/modifyendpoint doesn't indicate whether each operation created a new record or updated an existing one, so this implementation provides the documented workaround pattern.Key Changes
OperationClassification: A newSendable,Equatablestruct that partitions record operations into creates and updates by comparing proposed record names against existing ones. Provides three initializers:RecordOperationsequencesBatchSyncResult: A new public struct that categorizes modify response records into four buckets:created: Records whose names were in the creates setupdated: Records whose names were in the updates setfailed: Records withisError == true(takes precedence over classification)unclassified: Successful records not in either set (e.g., anonymous creates with server-assigned names)Provides computed properties for counts and totals, plus two initializers (direct array construction and classification-based partitioning).
CloudKitService+Classification: Extension adding two helper methods:fetchExistingRecordNames(recordType:): Queries existing record names for a type (max 200 records per request)modifyRecords(_:classification:atomic:): Overload that performs a modify and returns aBatchSyncResultwith pre-partitioned recordsImplementation Details
recordType == "Unknown") are always routed to thefailedbucket regardless of classification, preventing false positivesBatchSyncResultinitializer processes records in priority order: errors first, then creates, then updates, then unclassifiedSendablefor async/await compatibilityhttps://claude.ai/code/session_01VxC2K7NDYP48kj3JdtYrMX
Perform an AI-assisted review on