Everything started with this warning.

MockCKRecord Sendable warning

You see this warning in your console when you run Canopy tests or use Canopy’s MockCKRecord type in your own code, with Canopy versions earlier than 0.5.0.

Canopy 0.5.0 is a fix to this warning, and you no longer see it in this version.

To fix this warning, I re-thought a significant part of Canopy’s types.

TL;DR

All Canopy request results now return CloudKit record values as CanopyResultRecord.

You should access the data via CanopyResultRecordType protocol, which is identical to CloudKit API in terms of getting data. There are no setters, it’s a read-only view into the record.

For mocking CloudKit data with Canopy, you should now use MockReplayingContainer and MockReplayingDatabase in most of your tests. (This is not strictly related to the previous points, but emerged as a natural ergonomic improvement.)

The rest of this post is a deeper look.

The problem

CKRecord is a central part of CloudKit API. You use CKRecord both to store values into CloudKit, and to retrieve values out of CloudKit.

There are some fields of CKRecord that are only set by the CloudKit system on the server side, and that the client should treat as immutable: creationDate, creatorUserRecordID, modificationDate, and lastModifiedUserRecordID. For production use, this is fine. In a system like Canopy, though, there needs to be a way to override values of these fields for mocking and testing purposes.

Previously, Canopy offered MockCKRecord that essentially did this for all these fields:

public class MockCKRecord: CKRecord {
  public static let testingCreatorUserRecordNameKey = "testingCreatorUserRecordNameKey"
  
  override public var creatorUserRecordID: CKRecord.ID? {
    guard let testing = self[MockCKRecord.testingCreatorUserRecordNameKey] as? String else {
      return nil
    }

    return CKRecord.ID(recordName: testing)
  }
}

This worked fine, even if it was a little bit hacky. But in the Swift Concurrency world with Sendable, this no longer flies, and produces the above error.

It’s likely that CloudKit won’t actually change any time soon, and the above MockCKRecord would remain working. At least the Apple platform of 2024 (iOS 18 and macOS 15) is not this “near future release.”

Nevertheless, this all felt iffy, and triggered an introspection of what all the related Canopy API-s should really mean and be, and how to best express the desired behavior in the new Swift world of 2024.

CKRecord’s role in Canopy, and designing the Swift API with protocols

The above MockCKRecord was a somewhat half-hearted approach to mocking, which now hit a Swift language design limit with Sendable.

There are two avenues for mocking that I could now have taken. First, since CKRecord is an Objective-C object, I could perhaps have mocked it with something like OCMock or anything else that hacks the ObjC runtime to provide the desired mock functionality. Objective-C remains a supported language and this probably would have worked with less changes to Canopy that the other approach I took. But, this didn’t feel future-proof and good.

The other approach, which I ended up taking, is thinking in terms of protocol-oriented design that Swift encourages, and ask the question: what is the role, the job, of CKRecord in CloudKit and Canopy? And what are some other ways to represent these jobs in API-s, other than using CKRecord directly?

CKRecord is a feature-rich object that has two distinct jobs in CloudKit that mirror each other:

  1. represent the local state of a record to CloudKit (posting records to CloudKit)
  2. represent the CloudKit state of a record to local state (receiving records from CloudKit)

These are related but distinct. Since the problem with mocking only occurs with 2 but not 1 (indeed, in this version of Canopy there are no changes to the API parameters that upload state to CloudKit, which continue to use CKRecord), we can just constrain ourselves to 2 and ask, are there other ways to represent an object’s state, besides using CKRecord directly?

CKRecord is a mutable object. We can simplify the problem by stating that for transferring record representations from CloudKit into local state, we are only interested in immutable read-only representation that should be short-lived and get transformed from CKRecord-like-state into local storage state without mutations to the CKRecord-like-state.

Canopy 0.5.0 captures this idea in the CanopyResultRecordType protocol:

public protocol CanopyRecordValueGetting {
  subscript(_ key: String) -> CKRecordValueProtocol? { get }
}

protocol CanopyResultRecordType: CanopyRecordValueGetting {
  var encryptedValues: CanopyRecordValueGetting { get }
  var recordID: CKRecord.ID { get }
  var recordType: CKRecord.RecordType { get }
  var creationDate: Date? { get }
  var creatorUserRecordID: CKRecord.ID? { get }
  var modificationDate: Date? { get }
  var lastModifiedUserRecordID: CKRecord.ID? { get }
  var recordChangeTag: String? { get }
  var parent: CKRecord.Reference? { get }
  var share: CKRecord.Reference? { get }
}

This represents the entirety of CKRecord API (at least all the parts that Tact needs) constrained to the idea of getting the record value, but not being able to set it. Whenever you see CanopyResultRecord in Canopy API-s, you should most of the time think CanopyResultRecordType and just work with this protocol.

The API of CanopyResultRecordType is identical to CKRecord for the getters, and that’s intentional, to ease the call site migration. For most use, there shouldn’t be any migration required at all in the feature code that just reads CKRecord values and transforms them into local state.

CanopyResultRecord

CanopyResultRecord is a new type that represents a record as read-only. Internally, it’s powered by one of two data sources: either a CKRecord (received from CloudKit or created locally, both are fine), or another new type, MockCanopyResultRecord that’s a simple value type where you can set all the above fields on creation, which is what you often want to do in unit tests.

CanopyResultRecord is both Codable and Sendable, meaning that you can easily transfer it across isolation domains, and archive and unarchive single objects and their collections to shuffle around representations of entire mock databases and containers.

New mock database and container types

When working on the above, it occurred to me to simplify approach to testing and how you set up the test data. Previously, the way to do this in Canopy was with ReplayingMockCKContainer and ReplayingMockCKDatabase that provided mocked representations CKContainer and CKDatabase state. You initialized them with CloudKit API responses, and the data flowed through the entire Canopy logic and got transformed into a Canopy API response.

Doing this remains supported, and is an important tool for testing Canopy’s own functionality and logic. I realized, though, that if you are working on application code, it would be easier to mock Canopy API responses directly. You can now do this with the new types ReplayingMockContainer and ReplayingMockDatabase.

This means that when properly coded against Canopy, your feature code may be receiving real or simulated CloudKit responses from three places:

  • From real CloudKit via CKDatabase and CKContainer
  • From simulated CKDatabase and CKContainer via ReplayingMockCKDatabase and ReplayingMockCKContainer
  • From simulated Canopy API via the new ReplayingMockDatabase and ReplayingMockContainer

The nice thing is that your feature code with Canopy doesn’t care which of these three it is, and continues to work all the same, with everything nicely testable.

The diagram below summarizes the possible paths that a database request may take in Canopy. Note how only the live use hits iCloud. The other two remain entirely local to the device, and in both cases, the dependencies are controlled.

Types in Canopy 0.5.0

Extras

Above were the most important changes in Canopy 0.5.0. I ran into a few more things that I’ll document here.

CKShare

Canopy record mocking has currently no explicit support for CKShare. You can’t mock CKShare the same way that you mock CKRecord. Internally, Tact subclasses CKShare the same way that Canopy subclassed CKRecord, and it yields a similar warning as you see in the beginning of this post.

CKShare is a strange class in that subclasses CKRecord, but they actually have very little in common, and are doing very different jobs in CloudKit. Inheriting from a common base class or implementing a protocol for the base metadata properties would make more sense, but the CloudKit API design is stable and unlikely to change.

Canopy 0.5.0 provides a small escape hatch for working with CKShares. CanopyResultRecord has this getter: public var asCKShare: CKShare? So if you initialize CanopyResultRecord with a real CKShare, you can get it back this way.

Proper mocking of CKShare may get added in a future Canopy version.

Using DocC across multiple SPM targets

The Canopy documentation site is currently half-broken about the types documentation, because Canopy types are spread across multiple Swift Package Manager targets, and DocC does not currently support generating documentation out of box for such case. This thread in Swift Forums links to ongoing work about this.

There is a more hacky way to do this, but I decided this is currently not worth the maintenance effort for Canopy, so I am waiting for this feature in the official toolchain.

Inline documentation for Canopy in Xcode works fine and is not affected by this. It’s only the DocC documentation site suffering from this shortcoming.

Xcode swift-frontend compiler hallucinating errors in GitHub Actions

I was using GitHub Actions with Xcode runner for Canopy’s CI, to build and test the code. This worked fine for a while, but with Canopy 0.5.0, Xcode in GitHub Actions, more specifically swift-frontend, started hallucinating a nonsensical error. GitHub Actions community thread. Swift bug. Link to a failed job.

The code builds and runs fine for me locally with the same Xcode version (15.4), the code is warning-free and error-free.

I suspect that it is something on the GitHub Actions setup side, because I moved Canopy CI to CircleCI, where this error does not occur. CircleCI has a nice open source offer which I am happy to use and recommend now.