Skip to content

YapDatabaseCloudKit

Dr. Michael 'Mickey' Lauer edited this page Mar 23, 2018 · 72 revisions

You're 3 blocks away from supporting CloudKit in your app.

YapDatabaseCloudKit is an extension that allows you to sync your data with Apple's CloudKit system.

Sample Code

There's a sample project to accompany this wiki article. It's called "CloudKitTodo", and you can find it in the project repository under:

screen shot 1 screen shot 2

It won't win any awards for UI design. But then again, it's not trying to. The point of the application is simply to demonstrate how easy it is to add CloudKit support to your app using YapDatabaseCloudKit. And how few lines of code it really takes.

So feel free to open the project, and use it as a reference as you continue reading along. This wiki article will give you the overall picture of what the extension does and how it works. And you can refer to the sample code to fill in any missing details.

What you must know about CloudKit

CloudKit is a new technology, announced at WWDC 2014. And being new it naturally comes with a degree of misinformation. This is possibly exasperated by the fact that Apple has had similar technologies in the past, albeit with drastically different API's. So I feel it's important to clarify several points concerning CloudKit before we move on to YapDatabaseCloudKit details.

Apple is very explicit about what CloudKit is, and is not. From Apple's documentation:

  • "CloudKit is not a way to store data locally."
  • "CloudKit is not meant to replace your app’s existing data model objects."
  • "CloudKit is a service for moving data to and from the cloud."
  • "The classes of the CloudKit framework are not meant to be subclassed."

There are two very important takeaways from this:

  1. CloudKit is not a local database.

The CloudKit framework is a transport mechanism to get your data uploaded & downloaded. In fact, Apple even goes so far as to say "[CloudKit] provides minimal offline caching support". So YapDatabase is naturally complementary to the CloudKit service by providing the missing local storage component.

  1. CKRecord is not meant to be your data model object.

CKRecords are at the heart of all data transactions in CloudKit. But "the CKRecord class does not support any special customizations and should not be subclassed." (You'll notice this is a drastic departure from NSManagedObject.) In other words, Apple wants you to use your own data model classes. And then, when you're ready to sync that data up to the cloud, you copy the values from your own data model objects into a CKRecord, and then you hand that CKRecord off to the CloudKit framework.

It works the same way in the other direction. When you pull down data from the cloud, you'll be handed CKRecord objects. But Apple doesn't recommend you use these directly. They want you to copy the information out of the CKRecord into your own data model objects.

Again YapDatabase provides the perfect complement to CloudKit. With YapDatabase, you can store any kind of object. So you can create plain ole' object classes, tailored to fit your data model needs, and store that in YapDatabase. And then you can use YapDatabaseCloudKit to provide the hand-off between your objects and corresponding CKRecords.

I hope this helps to clarify any confusion (if you had any at all). Going forward we'll be discussing YapDatabaseCloudKit, and how it integrates YapDatabase (local storage) with CloudKit (cloud storage). A familiarity with CloudKit is assumed. So if you haven't already, you may want to take a look at Apple's own documentation, or WWDC videos, concerning CloudKit. As usual, they've done an amazing job with the docs, so I won't bother attempting to "out-doc" them.

Understanding YapDatabaseCloudKit Internals

There are 2 components to a CKRecord. The first is the set of key/value pairs that you provide. So you decide the key names, class types, and values that make up the data portion of a CKRecord. The second is the sync related metadata, which is mostly visible to us, but remains completely immutable to us. For example, every CKRecord has a 'recordChangeTag':

"Every time a record is saved, the server updates the record’s change token to a new value. When you save your instance of the record to the server, the server compares the token in your record with the token on the server. If the two tokens match, the server knows that you modified the latest version of the record and that your changes can be applied right away."

Only the CloudKit framework is capable of setting this value. This is important to understand, because it means that CKRecords aren't completely ephemeral. We're expected to store at least the metadata information for a CKRecord, so that when we make future modifications, we can present a CKRecord to the server with the proper sync related metadata (like the recordChangeTag). Luckily Apple provides a method which allows us to encode ONLY this metadata: encodeSystemFieldsWithCoder:

"Use this method to encode only the system-provided metadata associated with the record."

YapDatabaseCloudKit uses this method to store the "base" record for you. So if you insert a new object in YapDatabase, and create a corresponding new CKRecord, then YapDatabaseCloudKit will automatically:

  • Store the "base" CKRecord for you (system fields only, not the key/value stuff)
  • Push the CKRecord up to the cloud
  • Automatically update the "base" CKRecord when Apple gives it back to us via the completion block (with updated sync metadata, such as the recordChangeTag)

Thus your data isn't being needlessly duplicated in the database. Your key/value pairs live in your own data model objects. And YapDatabaseCloudKit handles storing the base CKRecords (metadata only). It also handles storing/managing "the queue".

As you make changes to your local objects (those that you're syncing), you're essentially generating CKModifyRecordsOperation(s). (i.e. pushing the changes to the cloud) But we can't simply assume these operations succeed. An internet connection might not be available. Or the network goes down mid upload. Or the app crashes. Or the change might fail because of a conflict. Etc, etc. So YapDatabaseCloudKit persists your "change-set queue" for you. That way if an upload fails because of a down network or an app crash, then it can properly pick-up where it left off when the network comes back up or the app is relaunched.

What's nice is that YapDatabaseCloudKit operates within the same atomic commit you're using to modify your model object. That is, when you create a readWriteTransaction, either the transaction will complete 100%, or not at all. (That's what 'atomic' means.) You don't have to worry about your data being in an inconsistent state. And this transaction includes both the changes you made to your object(s), and the "change-set" that you instruct YapDatabaseCloudKit to push to the server. Meaning that if your commit succeeds you'll know YapDatabaseCloudKit has also written information about what needs to get uploaded to the CloudKit servers.

And YapDatabaseCloudKit optimizes the storage of these "change-sets". It's quite simple conceptually. The values that exist in your native data model object represent "truth". Where the term "truth" here represents truth locally. (On this device / in this database) The idea is that YapDatabaseCloudKit doesn't need to store "truth", since it's already stored elsewhere, and it can retrieve/restore the truth value anytime it likes. Let's look at a concrete example:

Say you have a contact object in your database which you're syncing with CloudKit. The value of the "firstName" property for a particular contact is "Robert". Then the user modifies the contact, changes the firstName to "Robbie", and hits save. At this point you perform a readWriteTransaction, and change contact.firstName to "Robbie". This also creates a corresponding "modified" CKRecord with one key/value pair, representing the change that was made. (CKRecord: changedKeyPairs={ "firstName" : "Robbie" }) So YapDatabaseCloudKit will end up storing something like this:

  • ChangeSet[0]: databaseIdentifier=X, changes=[ {obj_rowid=Y, ckrecord_rowid=Z, changedKeys=["firstName"]} ]

The general idea here is that YapDatabaseCloudKit can optimize the storage of "truth" values. That is, if there are values in the CKRecord that correspond with the most recent values in your database object, then YapDatabaseCloudKit doesn't need to store the value. It really only needs to store the changed property name. And if the app crashes, then on relaunch it has a mechanism to easily restore the value of the "firstName" property. The end result being an optimization on what's written to disk, without any sacrifices being made on the part of reliability. And all the while still preserving the order of changes, and their values along the way.

This should give you a high level overview of what YapDatabaseCloudKit stores, and more importantly, what it does not store. The main takeaways (the good news) is:

  • YapDatabaseCloudKit allows me to use native data model objects, tailored to my needs
  • YapDatabaseCloudKit handles the majority of the CKRecord stuff
  • YapDatabaseCloudKit optimizes the storage of CKRecords
  • YapDatabaseCloudKit stores my "change-sets" within the same atomic transaction in which I create them
  • YapDatabaseCloudKit even optimizes the storage of these "change-sets" automatically
  • The basic principles follow Apple's guidelines on how to use CloudKit

Now let's dive into YapDatabaseCloudKit's API.

YapDatabaseCloudKit API

There are only 3 blocks that you need to implement in order to start using YapDatabaseCloudKit. They are:

  • The recordHandlerBlock, which is used to copy values from your custom objects into a CKRecord, in order to push that CKRecord up to the cloud.
  • The mergeBlock, which is used to merge the changes from other devices into your local data store.
  • The operationErrorBlock, which is used to handle various errors.

RecordHandlerBlock

The recordHandlerBlock is the primary mechanism that is used to tell YapDatabaseCloudKit about CKRecord changes. That is, as you make changes to your own custom data model objects, you can use the recordHandlerBlock to tell YapDatabaseCloudKit about the changes that were made by handing it CKRecords. Here's the general idea:

  • You update an object in the database via the normal setObject:forKey:inCollection method
  • Since YapDatabaseCloudKit is an extension, it's automatically notified that you modified an object
  • YapDatabaseCloudKit then invokes the recordHandler, and passes you the modified object, along with an empty base CKRecord (if available), and asks you to set the proper values on the record
  • Afterwards, the extension will check to see if it needs to upload the CKRecord (if it has changes), and handles the rest if it does

This all sounds very abstract, so let's jump ahead a few steps and show some code. Then we'll go over the code and fill in all the details.

YapDatabaseCloudKitRecordHandler *recordHandler = [YapDatabaseCloudKitRecordHandler withObjectBlock:
    ^(YapDatabaseReadTransaction *transaction, CKRecord *__autoreleasing *inOutRecordPtr,
      YDBCKRecordInfo *recordInfo, NSString *collection, NSString *key, id object)
{
    // We're only syncing todo items (for now)
    if (![object isKindOfClass:[MyTodo class]])
    {
        // This object isn't included in our CloudKit related activities
        return;
    }

    __unsafe_unretained MyTodo *todo = (MyTodo *)obj;

    CKRecord *record = inOutRecordPtr ? *inOutRecordPtr : nil;
    if (record                          && // not a newly inserted object
        !todo.hasChangedCloudProperties && // no sync'd properties changed in the todo
        !recordInfo.keysToRestore        ) // and we don't need to restore "truth" values
    {
        // Thus we don't have any changes we need to push to the cloud
        return;
    }

    // The CKRecord will be nil when we first insert an object into the database.
    // Or if we've never included this item for syncing before.
    //
    // Otherwise we'll be handed a base CKRecord, with only the proper CKRecordID
    // and the sync metadata set.

    BOOL isNewRecord = NO;

    if (record == nil)
    {
        CKRecordZoneID *zoneID =
          [[CKRecordZoneID alloc] initWithZoneName:@"zone1" ownerName:CKOwnerDefaultName];

        CKRecordID *recordID = [[CKRecordID alloc] initWithRecordName:todo.uuid zoneID:zoneID];

        record = [[CKRecord alloc] initWithRecordType:@"todo" recordID:recordID];

        *inOutRecordPtr = record;
        isNewRecord = YES;
    }

    id <NSFastEnumeration> cloudKeys = nil;

    if (recordInfo.keysToRestore)
    {
        // We need to restore "truth" values for YapDatabaseCloudKit.
        // This happens when the extension is restarted,
        // and it needs to restore its change-set queue (to pick up where it left off).

        cloudKeys = recordInfo.keysToRestore;
    }
    else if (isNewRecord)
    {
        // This is a CKRecord for a newly inserted todo item.
        // So we want to get every single property,
        // including those that are read-only, and may have been set directly via the init method.

        cloudKeys = todo.allCloudProperties;
    }
    else
    {
        // We changed one or more properties of our Todo item.
        // So we need to copy these changed values into the CKRecord.
        // That way YapDatabaseCloudKit can handle syncing it to the cloud.

        cloudKeys = todo.changedCloudProperties;
        
        // We can also instruct YapDatabaseCloudKit to store the originalValues for us.
        // This is optional, but comes in handy if we run into conflicts.
        recordInfo.originalValues = todo.originalCloudValues;
    }

    for (NSString *cloudKey in cloudKeys)
    {
        id cloudValue = [todo cloudValueForCloudKey:cloudKey];
        [record setObject:cloudValue forKey:cloudKey];
    }
}];

The recordHandlerBlock is where you hand-off changes you made to YapDatabaseCloudKit. If this was english, and not code, the recordHandlerBlock would be the following conversation:

  • Hey, I noticed that you just modified that Todo item.
  • Do you have any changes you'd like me to sync to the CloudKit server?
  • If so, just fill out this CKRecord for me and I'll take care of the rest.

Or alternatively:

  • Hey, during the last app run, you told me to upload the "isCompleted" property of this Todo item.
  • I didn't get a chance to sync that because the device was in airplane mode.
  • So could you please tell me what the value of this property is?
  • Just put the value in this here CKRecord and I'll take care of the rest.

If you inspected the code carefully, you probably have a rather important question at this point:

How do I know which properties were changed on an object, within the context of the recordHandlerBlock ?

I have an example solution for this. One that will make things incredibly easy for you. But before I present it I want to re-iterate something about YapDatabase really quick.

As you might recall from the Storing Objects wiki, YapDatabase doesn't care what kind of objects you use. As long as you can serialize & deserialize the object, YapDatabase will happily store it for you.

Similarly YapDatabaseCloudKit doesn't care what kind of objects you use. As long as you can get the values into a CKRecord it will happily sync it for you.

The YapDatabase project includes sample code for a MyDatabaseObject class (.h, .m). This is a really small, really simple piece of code that provides support for two things. First, it allows you to make objects immutable:

@property (nonatomic, readonly) BOOL isImmutable;

- (void)makeImmutable;

If you attempt to modify any property of the object (after invoking makeImmutable), it will throw an exception. It does this using some simple key-value observing (KVO). And this immutability technique is something we discuss in other wiki articles in order to provide better concurrency, and a safer multi-threaded environment.

This class also has properties such as:

@property (nonatomic, readonly) NSSet *allCloudProperties;

@property (nonatomic, readonly) NSSet *changedCloudProperties;
@property (nonatomic, readonly) BOOL hasChangedCloudProperties;

@property (nonatomic, readonly) NSDictionary *originalCloudValues;

The same simple KVO it uses to make sure you're not mutating an immutable object is used to track what it is you have been mutating. So you can just invoke these methods to find out what's changed, or to find out what's changed within the sync related subset.

I discuss this class in more detail later, in the section MyDatabaseObject Tricks.

Again, you're not required to use this as your base class. But I highly recommend taking a good hard look at the KVO techniques it employs. Ultimately, adding CloudKit support to your app means one thing: monitoring which properties you change, and copying those values into a CKRecord. And there's an easy way and a hard way to go about doing this. Personally, I prefer the easy way - which is to build these monitoring capabilities into your data model object(s).

So if you decide to use the MyDatabaseObject class, then make it your own. Feel free to rename it. Give it the same prefix you're using for your other object classes. Tweak it however you want to fit your needs. And then use it as the base class for anything where it could be helpful. Which obviously includes stuff you'll be wanting to sync with CloudKit. And if you choose to go this route, then the sample recordHandlerBlock above will look very very similar to your own version.

The PreSanitizer & PostSanitizer

Starting in v2.6, YapDatabase supports a PreSanitizer block & PostSanitizer block. These become especially handy when you're using YapDatabaseCloudKit. Basically, when you invoke the method setObject:forKey:inCollection::

The PreSanitizer runs at the beginning of the method:

  • before the object is serialized
  • before the object is put into the cache
  • before the object is passed to the extensions

The PostSanitizer runs at the end of the method:

  • after the object is serialized
  • after the object is put into the cache
  • after the object is passed to the extensions

We can use these as follows:

  • The PreSanitizer can make the object immutable
    • so it becomes thread-safe
    • so we can share it between database connections
  • The PostSanitizer clears the changedProperties
    • so we can cleanup after YapDatabaseCloudKit has processed it

So if you're using MyDatabaseObject or something similar, you can do configure YapDatabase like so:

YapDatabasePreSanitizer preSanitizer = ^(NSString *collection, NSString *key, id object){
    if ([object isKindOfClass:[MyDatabaseObject class]]) {
        [object makeImmutable];
    }
    return object;
};
YapDatabasePostSanitizer postSanitizer = ^(NSString *collection, NSString *key, id object){
    if ([object isKindOfClass:[MyDatabaseObject class]]) {
        [object clearChangedProperties];
    }
};

database = [[YapDatabase alloc] initWithPath:databasePath
                                  serializer:serializer
                                deserializer:deserializer
                                preSanitizer:preSanitizer
                               postSanitizer:postSanitizer
                                     options:nil];

Let's look at a concrete example. You likely already have code somewhat similar to this throughout your application:

[databaseTransaction asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction){

    MyTodo *todo = [transaction objectForKey:todoID inCollection:@"todos"];
    todo = [todo copy]; // make mutable copy
    todo.isComplete = YES;
    
    [transaction setObject:todo forKey:todoID inCollection:@"todos"];
}];

What will happen is the following:

  • When todo.isComplete is changed, then todo.changedProperties becomes {[ "isComplete" ]}
  • When setObject:forKey:inCollection is invoked, it first executes the PreSanitizer which will make the todo object immutable.
  • Then the method internals forward the todo object to YapDatabaseCloudKit
  • And YapDatabaseCloudKit invokes the recordHandlerBlock with the todo
  • The recordHandlerBlock sees that todo.changedProperties == {[ "isComplete" ]}, and so sets the record.isComplete value appropriately
  • The setObject:forKey:inCollection: then invokes the PostSanitizer (at the end of its routine), and the changedProperties are cleared
  • Thus the todo item is "cleaned" and ready to be modified again

Long story short: When you combine the PostSanitizer, recordHandler, and some KVO magic (like MyDatabaseObject) then you get a lot of functionality for free.

Alternative to recordHandlerBlock

Do I have to use the recordHandlerBlock? In my situation it would be easier if I could just straight hand you a CKRecord myself.

That's supported too !

I mentioned the recordHandlerBlock first because, in my opinion, that is both the simplest and most elegant system for updating a CKRecord. But it may not always be feasible. Sure it's great for your own custom data model objects, where you control the whole stack. But what about those situations in which you're storing objects you didn't design? Or perhaps there are some weird migration issues you need to handle. Or you just have some development cycle tasks that you need to "once-off" and be done.

And so the API allows you to directly hand it a modified CKRecord whenever you want:

[databaseConnection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction){

    [transaction enumerateKeysAndObjectsInCollection:@"todos" withBlock:^(NSString *key, MyTodo *todo, BOOL **stop){
    
        BOOL completed = todo.isCompleted;
        CKRecord *record = [[transaction ext:@"cloudKit"] recordForKey:key inCollection:@"todos"];
        
        [record setObject:nil forKey:@"isCompeted"]; // Typo, forgot the 'l'
        [record setObject:completed forKey:@"isCompleted"];
        
        [[transaction ext:@"cloudKit"] saveRecord:record databaseIdentifier:nil];
    }];
}];

The databaseIdentifier

Apple's CloudKit supports multiple databases. In their documentation they often discuss the "public database" and "private database". The public database stores objects available to all users of the app, and the private database is the per-user database that stores objects only readable/writeable by the iCloud user.

But there are even more options. These public & private databases discussed exist in a CKContainer. The defaultContainer to be exact. And if one sets up the correct permissions, it becomes possible to share a container between apps. So App1 & App2 could possibly share the same public database. Which could be very handy in certain situations. For example, enterprise solutions with multiple apps, sharing the same global address book, stored in a CloudKit "public" database.

YapDatabaseCloudKit supports all these CloudKit databases. And it supports using multiple CloudKit databases simultaneously. It does so via the 'databaseIdentifier'. This is simply a string that you provide in order to identify the proper CloudKit database associated with a CKRecord. Let's take another look at the recordHandlerBlock signature:

^(YapDatabaseReadTransaction *transaction, CKRecord *__autoreleasing *inOutRecordPtr,
  YDBCKRecordInfo *recordInfo, NSString *collection, NSString *key, id object)

The second parameter is the CKRecord, which you need to set / update. And the third parameter is a YDBCKRecordInfo:

@interface YDBCKRecordInfo : NSObject

/**
 * This property allows you to specify the associated CKDatabase for the record.
 * 
 * In order for YapDatabaseCloudKit to be able to upload the CKRecord to the cloud,
 * it must know which CKDatabase the record is associated with.
 * 
 * If unspecified, the private database of the app’s default container is used.
 * That is: [[CKContainer defaultContainer] privateCloudDatabase]
 * 
 * If you want to use a different CKDatabase,
 * then you need to set recordInfo.databaseIdentifier within your recordBlock.
 * 
 * Important:
 *   If you specify a databaseIdentifier here,
 *   then you MUST also configure the YapDatabaseCloudKit instance with a databaseIdentifier block.
 *   Failure to do so will result in an exception.
**/
@property (nonatomic, copy, readwrite) NSString *databaseIdentifier;

The default databaseIdentifier is nil, which means [[CKContainer defaultContainer] privateCloudDatabase]. Thus if you're only using this particular CloudKit database (the most common), then you don't have to do anything special concerning the databaseIdentifier.

But if you intend to use other CloudKit databases, then it's really easy to do:

  1. Set a databaseIdentifierBlock, which maps from the databaseIdentifier string to a CKDatabase.
  2. Set the databaseIdentifier string for any CKRecord's that aren't going to defaultContainer/privateCloudDatabase.

For example:

YapDatabaseCloudKitDatabaseIdentifierBlock databaseIdentifierBlock;
databaseIdentifierBlock = ^CKDatabase* (NSString *databaseIdentifier){

    if ([databaseIdentifier isEqualToString:@"public"])
        return [[CKContainer defaultContainer] publicCloudDatabase];
    else
        return nil;
};

You pass the databaseIdentifierBlock to YapDatabaseCloudKit via its init method. And then you specify the databaseIdentifier from within the recordHandlerBlock like so:

^(YapDatabaseReadTransaction *transaction, CKRecord *__autoreleasing *inOutRecordPtr,
  YDBCKRecordInfo *recordInfo, NSString *collection, NSString *key, id object)
{
    // ...

    if (record == nil)
    {
        // ...

        *inOutRecordPtr = record;
        recordInfo.databaseIdentifier = @"public";
    }
    
    // ...
}

YapDatabaseCloudKit will store the databaseIdentifier alongside the base CKRecord. And when it invokes the recordHandlerBlock due to a modified item, it will pass the base CKRecord, and will have the previous databaseIdentifier already set in recordInfo.databaseIdentifier. So you really only need to set recordInfo.databaseIdentifier once, when you first create the record.

And, as you can see, the databaseIdentifier is really just an opaque string to YapDatabaseCloudKit. So the string can be whatever you want. YapDatabaseCloudKit doesn't do anything with it besides store it, and invoke your databaseIdentifierBlock (if needed).

MergeBlock

The second required block is the mergeBlock. This block is used to merge a CKRecord, which may come from a different device or different user, into the local system. For example, we receive a CKRecord which contains changes to a MyContact item that were made on another device. The mergeBlock is used to perform two tasks:

  1. It allows you to merge changes (generally made on a different machine) into you local data model object.
  2. It allows you to modify YapDatabaseCloudKit's change-set queue in the event there are any conflicts.

Let's go over two scenarios, one in which there is no conflict, and one in which there is. Afterwards we'll go over an example mergeBlock implementation.

Scenario 1 - Conflict Free

Let's say a CKRecord is received for a contact. The CKRecord indicates that contact.firstName was changed to "Robbie". We have not made any changes to this contact on our local machine. So the merge operation is very straight-forward. We simply fetch the corresponding MyContact object, set its firstName property to "Robbie", and save it back into the database.

Scenario 2 - Possible Conflict Detected

Let's say a CKRecord is received for a contact. The CKRecord indicates that contact.firstName was changed to "Robbie". However, we also modified the contact on our local machine recently. And we changed contact.firstName to "Robby". Furthermore, our change hasn't been uploaded to the CloudKit server yet. It had been sitting in YapDatabaseCloudKit's change-set queue, awaiting internet connectivity. So how to handle this situation?

To break it down, we have two options:

  1. The remote machine wins, and contact.firstName == "Robbie". We update our local contact accordingly, and instruct YapDatabaseCloudKit not to bother uploading that particular change for the firstName property.
  2. The local machine wins, and contact.firstName == "Robby". We do not modify our local contact, and we instruct YapDatabaseCloudKit to continue as planned, and still upload the change for the firstName property.

(If you wanna get really fancy, then you could do something really advanced like mark the field as having a conflict, and save both values. You even have a pimped out UI that highlights these conflicts, and allows users to pick which one they want. But that's a topic for another day. Let's keep it simple for now so we can get the basic concepts down first.)

The mergeBlock give you the following parameters:

  • YapDatabaseReadWriteTransaction - which allows you to modify the contact in the database
  • collection & key - which allows you to fetch the corresponding contact
  • CKRecord *remoteRecord - the remote record, as received from the CloudKit server
  • YDBCKMergeInfo *mergeInfo
    • mergeInfo.pendingLocalRecord - all the pending changes that have been made to the contact locally, and that have yet to be uploaded to the CloudKit server.
    • mergeInfo.updatedPendingLocalRecord - an empty CKRecord, which you need to fill out in order to tell YapDatabaseCloudKit which changes you still want to push to the CloudKit server
    • mergeInfo.originalValues - If you stored the original values within your recordHandlerBlock, then they will be presented to you here

In the "conflict free" scenario, the pendingLocalRecord parameter will be nil.

In the "possible conflict detected" scenario, the pendingLocalRecord will be a merged record of all the CKRecords that are sitting in the change-set queue, and are pending upload to the CloudKit server. For example, the user may have modified the contact.firstName, set it to "Robby" and hit save. Then later, they went back and modified the contact.lastName property to "Hanson" and hit save again. In this case the pendingLocalRecord would contain key/value pairs for both firstName & lastName. The updatedPendingLocalRecord is an empty CKRecord (with the proper sync metadata) that you need to fill out. In particular, any values that you want to keep (that you still want YapDatabaseCloudKit to push to the server) you MUST copy into the updatedPendingLocalRecord.

So, for example, you may decide to keep the remotely set firstName ("Robbie"), and drop the locally set firstName ("Robby"). (First device wins protocol.) But you do, of course, want to keep the locally set lastName ("Hanson"). So you would update your MyContact object accordingly (by setting the contact.firstName to "Robbie"). And you would update updatedPendingLocalRecord with the one value you do want to keep (that you want YapDatabaseCloudKit to still push to the server): lastName="Hanson".

The following is a really simple mergeBlock which allows the "first" device to always win. That is, whichever device uploaded their change to the CloudKit server first wins.

YapDatabaseCloudKitMergeBlock mergeBlock =
  ^(YapDatabaseReadWriteTransaction *transaction, NSString *collection, NSString *key,
    CKRecord *remoteRecord, CKRecord *pendingLocalRecord, CKRecord *newLocalRecord)
{
    if ([remoteRecord.recordType isEqualToString:@"todo"])
    {
        MyTodo *todo = [transaction objectForKey:key inCollection:collection];
        todo = [todo copy]; // make mutable copy

        // CloudKit doesn't tell us exactly what changed.
        // We're just being given the latest version of the CKRecord.
        // So it's up to us to figure out what changed.

        NSArray *allKeys = remoteRecord.allKeys;
        NSMutableArray *remoteChangedKeys = [NSMutableArray arrayWithCapacity:allKeys.count];

        for (NSString *key in allKeys)
        {
            id remoteValue = [remoteRecord objectForKey:key];
            id localValue = [todo cloudValueForCloudKey:key];

            if (![remoteValue isEqual:localValue])
            {
                id originalLocalValue = [mergeInfo.originalValues objectForKey:key];
                if (![remoteValue isEqual:originalLocalValue])
                {
                    [remoteChangedKeys addObject:key];
                }
            }
        }

        NSMutableSet *localChangedKeys =
          [NSMutableSet setWithArray:mergeInfo.pendingLocalRecord.changedKeys];

        for (NSString *remoteChangedKey in remoteChangedKeys)
        {
            id remoteChangedValue = [remoteRecord valueForKey:remoteChangedKey];

            [todo setLocalValueFromCloudValue:remoteChangedValue forCloudKey:remoteChangedKey];
            [localChangedKeys removeObject:remoteChangedKey];
        }
        for (NSString *localChangedKey in localChangedKeys)
        {
            id localChangedValue = [mergeInfo.pendingLocalRecord valueForKey:localChangedKey];
            [mergeInfo.updatedPendingLocalRecord setObject:localChangedValue forKey:localChangedKey];
        }

        [transaction setObject:todo forKey:key inCollection:collection];
    }
};

Two things to notice:

  1. It's not rocket science
  2. We update both our data model object (MyTodo), and the updatedPendingLocalRecord

YapDatabaseCloudKit takes care of the rest. If the updatedPendingLocalRecord no longer contains a key/value pair for firstName, then it will scrap that change, and not bother pushing it to the cloud.

## Suspend & Resume

YapDatabaseCloudKit can be suspended & resumed at any time. When it's suspended, it continues to do all the things it normally does with one exception: it doesn't try to push any change-sets to the CloudKit server. Thus YapDatabaseCloudKit remains fully functional at all times when it's plugged into YapDatabase as an extension, but if you suspend it then it will temporarily pause uploading any change-sets.

The suspend operation actually works as a counter. That is, every-time you invoke the suspend method, it increments an internal 'suspendCount'. And every-time you invoke the resume method, it decrements the internal 'suspendCount'. When the suspendCount gets back to zero, then YapDatabaseCloudKit resumes uploading change-sets.

[ydCloudKit suspend]; // state=Suspended, suspendCount=1
[ydCloudKit suspend]; // state=Suspended, suspendCount=2
[ydCloudKit resume];  // state=Suspended, suspendCount=1
[ydCloudKit resume];  // state=Resumed,   suspendCount=0

For convenience, there is also a suspend method that take an increment parameter:

[ydCloudKit suspend:2]; // state=Suspended, suspendCount=2
[ydCloudKit resume];    // state=Suspended, suspendCount=1
[ydCloudKit resume];    // state=Resumed,   suspendCount=0

The suspend & resume operations come in handy for a variety of situations. Let's start by looking at what might need to be done the first time an app is ever launched on a user's phone (in the context of CloudKit):

  • We need to create the CKRecordZone(s)
  • We need to create the proper CKSubscription(s), possibly for the recordZone(s)
  • We need to pull the latest changes from the server

Now it doesn't make sense to let YapDatabaseCloudKit upload a record if the record's recordZone doesn't exist yet. The operation would simply fail (error == "recordZone does not exist"). And similarly it might be silly to start uploading change-sets if we're 20 commits behind the server. Probably better to pull all the changes first, resolve any necessary conflicts, and then push our changes.

So what we can do is start YapDatabaseCloudKit in a suspended state. That way it can still be integrated into our database, and do all the things we want it to do. But it will wait until we give it the signal before attempting to push anything to the server:

cloudKitExtension = [[YapDatabaseCloudKit alloc] initWithRecordHandler:recordHandler
                                                            mergeBlock:mergeBlock
                                                   operationErrorBlock:opErrorBlock
                                                            versionTag:@"1"
                                                           versionInfo:nil
                                                               options:options];

[cloudKitExtension suspend]; // Create zone(s)
[cloudKitExtension suspend]; // Create zone subscription(s)
[cloudKitExtension suspend]; // Fetch changes from other devices while app wasn't running

[database asyncRegisterExtension:cloudKitExtension withName:Ext_CloudKit completionBlock:^(BOOL ready) {
	if (!ready) {
		DDLogError(@"Error registering %@ !!!", Ext_CloudKit);
	}
}];

And then you simply invoke resume for each startup task that you need to do. For example:

modifyRecordZonesOperation.modifyRecordZonesCompletionBlock =
    ^(NSArray *savedRecordZones, NSArray *deletedRecordZoneIDs, NSError *operationError)
{
    if (!operationError)
    {
        // ... maybe set flag in database so we know we don't need to do this next time...

        [cloutKitExtension resume]; // decrement suspendCount
    }
};

For a complete code example, please see the 'CloudKitTodo' project that comes with the repository. It demonstrates these start-up tasks in full.

You can also use suspend & resume for any other purpose you need. The suspend & resume methods are thread-safe, and may be invoked at anytime.

OperationErrorBlock

The third (and final) required block is the operationErrorBlock. When YapDatabaseCloudKit goes to push a change-set to the server, it creates a CKModifyRecordsOperation. If that operation comes back with an error from the CloudKit Framework, then YapDatabaseCloudKit automatically suspends itself, and forwards the error to you via the operationErrorBlock. It's your job to look at the errorCode, decide what to do, and resume YapDatabaseCloudKit when ready.

Before we dive into the details, I want to paraphrase what the Apple Engineer said about CloudKit in the WWDC presentation (WWDC 2014 - Introducing CloudKit):

"For most frameworks, you're told, "You need to handle errors that are returned from our framework." And it's true. But for most frameworks, this error handling is the difference between a good app and a great app. CloudKit is different. CloudKit talks over the network. So with CloudKit, error handling is the difference between a functioning app and a non-functioning app."

So please banish from your mind the idea that the operationErrorBlock isn't important. Or that it's something you can do for a later release. YapDatabaseCloudKit already handles a lot of things for you. And, as you've seen above, the implementation of the recordHandlerBlock & mergeBlock isn't that intense. So the operationErrorBlock is your chance to shine. With Apple's CloudKit service & tools like YapDatabaseCloudKit, the bar has been significantly lowered for anyone who wants to add sync to their app. So spend some time thinking about the various errorCodes you could encounter, and how to handle them. How great will it be when you see reviews of your app in the App Store that say, "Sync just works. Never once had a problem."

Now let's look at a few error codes and how we can handle them:

YapDatabaseCloudKitOperationErrorBlock opErrorBlock =
    ^(NSString *databaseIdentifier, NSError *operationError)
{
    NSInteger ckErrorCode = operationError.code;

    if (ckErrorCode == CKErrorNetworkUnavailable ||
        ckErrorCode == CKErrorNetworkFailure)
    {
        // What can we do here ?
    }
    // ...
};

One obvious problem we could encounter would be no internet connectivity. To handle this we'll want to monitor the system for changes to connectivity. When we get a notification that indicates the internet may be available again, we can simply resume YapDatabaseCloudKit.

There's a big list of these CKErrorX code's that you should look through. Some of them are obvious as to how you would handle them. Others involve a bit more thought.

CKErrorZoneBusy = 23, /* The server is too busy to handle this zone operation. Try the operation again in a few seconds. */
// ...
CKErrorQuotaExceeded = 25, /* Saving a record would exceed quota */

I'm not going to go over every single error, but I do want to highlight the other most common error:

CKErrorPartialFailure = 2,  /* Some items failed, but the operation succeeded overall */

This error can be quite common, and is rather easy to handle. If you wish, you can extract from the NSError the exact CKRecordIDs which failed. But here's what YapDatabaseCloudKit does when it encounters this error:

  • It extracts all the CKRecordIDs which failed
  • It determines which succeeded
  • It removes all items which succeeded from the change-set
  • It then invokes the operationErrorBlock

The items that failed most likely did so because they were out-of-date. That is, some other device pushed a change to the same item(s), and that change hit the server before yours did. So all you need to do is:

  • Pull the latest changes from the server for the failed CKDatabase / CKRecordZone
  • Tell YapDatabaseCloudKit to merge the changes you pull down
  • Tell YapDatabaseCloudKit to resume

For an example implementation of the OperationErrorBlock, please see the CloudKitTodo example project.

Next we're going to discuss some fundamentals about how YapDatabaseCloudKit links objects in the database with CKRecords. But after that we're going to dive into the implementation details of how to fetch changes from the server, and how to tell YapDatabaseCloudKit to merge fetched changes.

Attach & Detach

YapDatabaseCloudKit works by associating one or more objects in the database with a CKRecord. The act of associating a row in the database with a CKRecord is referred to as "attaching". And similarly, the act of un-associating a row in the database with a CKRecord is referred to as "detaching".

You can perform these actions implicitly via the recordHandlerBlock. Or you can perform the actions explicitly using the attach & detach methods in the YapDatabaseCloudKitTransaction API.

We'll start by discussing the implicit actions, which represent the common-case handling. Then we'll discuss the explicit API, and its advanced options.

Let's say you insert an new Todo item into the database:

[databaseTransaction asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction){

    MyTodo *todo = [[MyTodo alloc] init];
    todo.title = @"Pick up eggs";
    todo.isDone = NO;

    [transaction setObject:todo forKey:todo.uuid inCollection:@"todos"];
}];

The act of inserting this new item will automatically invoke the recordHandlerBlock. And your recordHandlerBlock creates a new CKRecord (for the newly inserted todo item), populates the CKRecord with the proper key/value pairs, and hands this CKRecord to YapDatabaseCloudKit. Thus the MyTodo item is now "attached" to the CKRecord. To be more precise, the row in the main database table (represented by the collection/key tuple) is now associated with the row in the record table (represented by the CKRecordID/databaseIdentifier tuple).

And when you first create & attach a CKRecord in this manner (via the recordHandlerBlock), YapDatabaseCloudKit automatically adds the CKRecord to the change-set, and queues it for upload to the CloudKit server.

In the future, when you modify the todo item, YapDatabaseCloudKit can quickly & easily lookup the associated CKRecord & databaseIdentifier associated with the todo item.

And when you delete the todo item from the database, YapDatabaseCloudKit will see that the deleted row had an associated CKRecord. So it automatically marks the CKRecordID as deleted, adds this to the change-set, and queues it for upload to the CloudKit server.

These are the default/implicit actions. And they work well for the common-case. For more advanced operations there are the attach & detach methods.

- (BOOL)attachRecord:(CKRecord *)record
  databaseIdentifier:(NSString *)databaseIdentifier
              forKey:(NSString *)key
        inCollection:(NSString *)collection
  shouldUploadRecord:(BOOL)shouldUploadRecord;

The attach method allows you to make an explicit association without going through the recordHandlerBlock. There are a number of situations in which this comes in handy:

  1. You've already been using CloudKit, but you've been doing all the work by hand. Now you'd like to start using YapDatabaseCloudKit. You can use this method to hand over the reins. So you enumerate all those CKRecord's you've been managing by hand, invoke this method, and set 'shouldUploadRecord' to NO for any "clean" records, and YES for any "dirty" records that still need to be pushed.

  2. A different device created a new todo item, and you discovered it via a CKFetchRecordChangesOperation. So what you want to do is create a new MyTodo item in the database. And also associate it with the CKRecord you have in your hand. But obviously you want to tell YapDatabaseCloudKit that it doesn't need to upload this CKRecord. (We'll go over exactly how to do this in the next section, including a code example.)

There is also a corresponding detach method:

- (void)detachRecordForKey:(NSString *)key
              inCollection:(NSString *)collection
         wasRemoteDeletion:(BOOL)wasRemoteDeletion
      shouldUploadDeletion:(BOOL)shouldUploadDeletion;

Also handy for a number of situations:

  1. You need to perform some migration work for version 2 of your app. In particular, you're moving a bunch of existing CKRecords into a new zone. The native data model objects won't change. But you need to detach them from the old CKRecordID (with the old zone) in order to attach them to their new CKRecordID (with the new zone).

  2. You app allows users to specify where they want their todo items stored: local or cloud. A user has a todo item for "pickup diamond earrings for wife's b-day". But oops, they stored it in the cloud. Which means his wife can see it, since they share the same iCloud account. So he switches it to local storage. Meaning we need to delete the association, and delete the CKRecord from the cloud, but not delete the local MyTodo item. This method is one way of achieving all that.

  3. We discovered that a todo item was deleted by another device. We need to delete the corresponding local MyTodo item. But we also want to make sure YapDatabaseCloudKit understands that the CKRecord was already deleted remotely, so it doesn't try to also delete it. (We'll go over exactly how to do this in the next section, including a code example.)

#### Multiple Objects per CKRecord

The "association" mechanism of YapDatabaseCloudKit is quite flexible. This gives you a degree of freedom to diverge your local data model(s) from an "already-set-in-stone" CKRecord format. In particular, you are free to associate multiple objects within YapDatabase with the same CKRecord. This is not only possible, but fully supported by YapDatabaseCloudKit.

For example, one might have a base MyContact object, with the address stored separately in a MyAddress object. But not so with the CKRecord. Instead it just has a single "address" property on the contact. Not a problem. You can associate the MyAddress object with the same CKRecord as the MyContact. Thus updating this MyAddress object will result in updating the appropriate CKRecord.

This works by using a retainCount-like scheme, referred to within YapDatabaseCloudKit as the ownerCount. Thus if you associate two objects in the database with the same CKRecord, and then proceed to delete one of the objects, the associated CKRecord simply has its ownerCount decremented from 2 to 1. Of course, once the ownerCount drops to zero, the CKRecord is deleted from the sqlite database, as you would imagine. (Whether or not YapDatabaseCloudKit attempts to push a deleted CKRecordID depends on how you did the detaching.)

## Fetching Record Changes

With the CloudKit Framework you use the CKFetchRecordChangesOperation in order to pull down changes from the server. Thus when a different device makes changes to a record, you'll receive those changes via this operation. (You'll also receive your own changes back, but there's a way to detect this.)

It's important to process these correctly. In particular, we want to:

For any deleted CKRecordIDs:

  1. Tell YapDatabaseCloudKit that the item was deleted remotely (so it doesn't bother pushing an already deleted CKRecordID to the server)
  2. Remove the corresponding object from our local database

For any newly inserted CKRecords:

  1. Tell YapDatabaseCloudKit that the item was inserted remotely (so it doesn't bother pushing a record that's already on the server)
  2. Insert the corresponding object in our local database

For any modified CKRecords:

  1. Tell YapDatabaseCloudKit about the modified CKRecord so that it can:
  • Invoke the mergeBlock (which allows us to perform all the appropriate merge actions)
  • Update its change-set queue (if needed)

It's important to note that the order of operations specified above is important. The code sample below makes this point more explicitly.

- (void)fetchRecordChangesWithCompletionHandler:
    (void (^)(UIBackgroundFetchResult result, BOOL moreComing))completionHandler
{
    __block CKServerChangeToken *serverChangeToken = nil;

    [databaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {

        serverChangeToken = [transaction objectForKey:Key_ServerChangeToken
                                         inCollection:Collection_CloudKit];
    }];

    CKRecordZoneID *recordZoneID =
      [[CKRecordZoneID alloc] initWithZoneName:CloudKitZoneName ownerName:CKOwnerDefaultName];

    CKFetchRecordChangesOperation *operation =
      [[CKFetchRecordChangesOperation alloc] initWithRecordZoneID:recordZoneID
                                        previousServerChangeToken:serverChangeToken];

    __block NSMutableArray *deletedRecordIDs = nil;
    __block NSMutableArray *changedRecords = nil;

    __weak CKFetchRecordChangesOperation *weakOperation = operation;

    operation.recordWithIDWasDeletedBlock = ^(CKRecordID *recordID){

        if (deletedRecordIDs == nil)
            deletedRecordIDs = [[NSMutableArray alloc] init];

        [deletedRecordIDs addObject:recordID];
    };

    operation.recordChangedBlock = ^(CKRecord *record){

        if (changedRecords == nil)
            changedRecords = [[NSMutableArray alloc] init];

        [changedRecords addObject:record];
    };

    operation.fetchRecordChangesCompletionBlock =
      ^(CKServerChangeToken *serverChangeToken, NSData *clientChangeTokenData, NSError *operationError){

        if (operationError)
        {
            if (completionHandler) {
                completionHandler(UIBackgroundFetchResultFailed, NO);
            }
        }
        else
        {
            BOOL moreComing = weakOperation.moreComing;

            [databaseConnection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {

                // Handle items that were deleted
                for (CKRecordID *recordID in deletedRecordIDs)
                {
                    NSArray *collectionKeys =
                      [[transaction ext:Ext_CloudKit] collectionKeysForRecordID:recordID
                                                             databaseIdentifier:nil];

                    for (YapCollectionKey *ck in collectionKeys)
                    {
                        // This MUST go FIRST (A)
                        [[transaction ext:Ext_CloudKit] detachRecordForKey:ck.key
                                                              inCollection:ck.ollection
                                                         wasRemoteDeletion:YES
                                                       shouldUploadDeletion:NO];

                         // This MUST go SECOND  (B)
                         [transaction removeObjectForKey:ck.key inCollection:ck.collection];
                    }
                }

                // Handle items that were inserted or modified
                for (CKRecord *record in changedRecords)
                {
                    if (![record.recordType isEqualToString:@"todo"])
                    {
                        // Ignore unknown record types.
                        // These are probably from a future version that this version doesn't support.
                        continue;
                    }

                    NSString *recordChangeTag = nil;
                    BOOL hasPendingModifications = NO;
                    BOOL hasPendingDelete = NO;

                    [[transaction ext:Ext_CloudKit] getRecordChangeTag:&recordChangeTag
                                               hasPendingModifications:&hasPendingModifications
                                                      hasPendingDelete:&hasPendingDelete
                                                           forRecordID:record.recordID
                                                    databaseIdentifier:nil];

                    if (recordChangeTag)
                    {
                        if ([recordChangeTag isEqualToString:record.recordChangeTag])
                        {
                            // We're the one who changed this record.
                            // So we can quietly ignore it.
                        }
                        else
                        {
                            [[transaction ext:Ext_CloudKit] mergeRecord:record databaseIdentifier:nil];
                        }
                    }
                    else if (hasPendingModifications)
                    {
                        // We're not actively managing this record anymore (we deleted/detached it).
                        // But there are still previous modifications that are pending upload to server.
                        // So this merge is required in order to keep everything running properly
                        // (no infinite loops).

                        [[transaction ext:Ext_CloudKit] mergeRecord:record databaseIdentifier:nil];
                    }
                    else if (!hasPendingDelete)
                    {
                        MyTodo *newTodo = [[MyTodo alloc] initWithRecord:record];

                        NSString *key = newTodo.uuid;
                        NSString *collection = Collection_Todos;

                        // This MUST go FIRST (D)
                        [[transaction ext:Ext_CloudKit] attachRecord:record
                                                  databaseIdentifier:nil
                                                              forKey:key
                                                        inCollection:collection
                                                  shouldUploadRecord:NO];

                        // This MUST go SECOND (E)
                        [transaction setObject:newTodo forKey:newTodo.uuid inCollection:Collection_Todos];
                    }
                }

                // And save the serverChangeToken (in the same atomic transaction)
                [transaction setObject:serverChangeToken
                                forKey:Key_ServerChangeToken
                          inCollection:Collection_CloudKit];

            } completionBlock:^{

                if (completionHandler)
                {
                    if (([deletedRecordIDs count] > 0) || ([changedRecords count] > 0)) {
                        completionHandler(UIBackgroundFetchResultNewData, moreComing);
                    }
                    else {
                        completionHandler(UIBackgroundFetchResultNoData, moreComing);
                    }
                }
            }];

            if (moreComing)
            {
                [self fetchRecordChangesWithCompletionHandler:completionHandler];
            }
        }
    };

    [[[CKContainer defaultContainer] privateCloudDatabase] addOperation:operation];
}

This method is fetching changes for a particular CKRecordZone. It starts by fetching the previous serverChangeToken. This allows it to pick up from wherever it left off last time. Then it sets up the CKFetchRecordChangesOperation, and fires off the operation.

If the operation completes successfully then we're given a list of deleted CKRecordIDs and inserted/modified CKRecords. The processing of which is pretty straight-forward.

If something was deleted by another device, then we want to also delete the object on the local device. Except we're given a CKRecordID, and we want to know the associated collection/key of the item within our database. YapDatabaseCloudKit can answer this question for us quickly. So if we find out the CKRecordID is associated with an item in our local database, then we:

  1. Tell YapDatabaseCloudKit the item was remotely deleted (A)
  2. Delete the item from our local database (B)

Note: You may recall that it's legal to associate multiple items in the local database with a single CKRecord. This is why the boiler-plate template code asks for all attached collection/key tuples for the deleted CKRecordID. In most cases this array will just include one item, or be empty. But if you later end up associating multiple items with the same record for whatever reason, you can rest assured knowing this code will just work.

If something was inserted by another device, we do something very similar:

  1. Tell YapDatabaseCloudKit the item was inserted remotely (D)
  2. Create the proper data model object, and add it to the database (E)

The sample code only supports one type of object: MyTodo. Your own application will likely support multiple object types. So you'll be replacing that section with code that inspects the record.recordType, and creates the appropriate object.

The most complicated scenario is when an existing object has been modified by a remote device. Except you'll notice that all the complication is handled elsewhere. The only thing to do in this method is tell YapDatabaseCloudKit to run the proper merge routine for the record. It then handles setting up and running the mergeBlock, and automatically updating its change-set queue (if needed).

For a complete working code sample, please refer to the CloudKitTodo sample project.

MyDatabaseObject Tricks

Again, you're not required to use the MyDatabaseObject class. But the following sections discuss how it has gone about solving particular CloudKit related data issues. So even if you're planning on using something else, it may be worth your time to skim these sections in order to get an idea of the problems you may run into.

Properties we don't want to sync

We may store more locally than we want to sync. This is often device specific information. Like a push token. The MyDatabaseObject class provides a template solution for these kind of scenarios.

Let's say we have a MyUser object with the following device specific fields that we don't want to sync:

@property (readwrite) NSString *pushToken;
@property (readwrite) NSString *deviceID;

The MyDatabaseObject class has the following:

+ (NSMutableDictionary *)mappings_localKeyToCloudKey;

@property (nonatomic, readonly) NSSet *allCloudProperties;

@property (nonatomic, readonly) NSSet *changedCloudProperties;
@property (nonatomic, readonly) BOOL hasChangedCloudProperties;

The basic concept is that MyDatabaseObject understands:

  • there is a list of localKeys that should be monitored
  • there is a subset of these that also affect syncing the object to a cloud service
  • and there's a mapping from localKeyName to cloudKeyName

Long story short, you can configure all of this via the mappings_localKeyToCloudKey method. So all you have to do is override that method in the MyUser class like so:

+ (NSMutableDictionary *)mappings_localKeyToCloudKey
{
    NSMutableDictionary *mappings_localKeyToCloudKey = [super mappings_localKeyToCloudKey];
    [mappings_localKeyToCloudKey removeObjectForKey:@"pushToken"];
    [mappings_localKeyToCloudKey removeObjectForKey:@"deviceID"];

    return mappings_localKeyToCloudKey;
}

So now if you invoke 'allCloudProperties' on any MyUser object, it won't include 'pushToken' or 'deviceID'.

Property name mismatches

As your data model evolves, you may end up using slightly different names within your native data model object vs your CKRecord. For example, you may have originally used the property name 'completed' to store whether or not a todo item has been marked as completed. But you eventually standardized on using the property name 'isCompleted'. However, while this was easy to change in your own code base, its too much of a hassle to change server side.

Another issue you may run into: reserved property names in CKRecord. For example, CKRecord has a property named 'creationDate' (which is part of the internal metadata). Which means that you can't use that as a key in your CKRecord.

So let's say we have the following properties in our MyTodo class:

@property (readwrite) BOOL isCompleted; // <--- CKRecord key: "completed"
@property (readwrite) NSDate *creationDate; // <--- CKRecord key: "created"

We start by overriding the mappings_localKeyToCloudKey method in our MyTodo class:

+ (NSMutableDictionary *)mappings_localKeyToCloudKey
{
    NSMutableDictionary *mappings_localKeyToCloudKey = [super mappings_localKeyToCloudKey];
    [mappings_localKeyToCloudKey setObject:@"completed" forKey:@"isCompleted"];
    [mappings_localKeyToCloudKey setObject:@"created" forKey:@"creationDate"];

    return mappings_localKeyToCloudKey;
}

After we make this change, our list of 'allCloudProperties' and 'changedCloudProperties' will include "completed" instead of "isCompleted". And "created" instead of "creationDate". Now let's take another look at that recordHandlerBlock:

else
{
    // We changed one or more properties of our Todo item.
    // So we need to copy these changed values into the CKRecord.
    // That way YapDatabaseCloudKit can handle syncing it to the cloud.

    cloudKeys = todo.changedCloudProperties; // <--- {[ "completed" ]}
}

for (NSString *cloudKey in cloudKeys)
{
    id cloudValue = [todo cloudValueForCloudKey:cloudKey];
    [record setObject:cloudValue forKey:cloudKey];
}

The MyDatabaseObject object class has a mapping from localKey -> cloudKey. And it can also quickly perform the inverse: cloudKey -> localKey. So the 'cloudValueForCloudKey' method, by default, will simply map from "completed" to "isCompleted", and then invoke [self valueForKey:@"isCompleted"]. In other words, most of the time, it just works out of the box.

Unsupported property types

CKRecord only supports a handful of types:

  • NSString
  • NSNumber
  • NSData
  • NSDate
  • NSArray
  • CLLocation
  • CKAsset
  • CKReference

So what if you want to sync something else, like a color property? On iOS this would be a UIColor object. And on Mac OS X it would be a NSColor property.

Let's say that our MyTodo class has a property named color:

#if TARGET_OS_IPHONE
@property (readwrite) UIColor *color;
#else
@property (readwrite) NSColor *color;
#endif

We decide we want to store these in the CKRecord object using a string. And we create some utility functions that convert from UIColor/NSColor to an RGBA string. And the inverse.

  • MYColorToString()
  • MYStringToColor()

Then it's just a simple matter of overriding two methods to handle the conversion:

- (id)cloudValueForCloudKey:(NSString *)cloudKey
{
    if ([cloudKey isEqualToString:@"color"])
    {
        // We store UIColor in the cloud as a string (r,g,b,a)
        return MYColorToString(self.color);
    }
    else
    {
        return [super cloudValueForCloudKey:cloudKey];
    }
}

- (void)setLocalValueFromCloudValue:(id)cloudValue forCloudKey:(NSString *)cloudKey
{
    if ([cloudKey isEqualToString:@"color"])
    {
        // We store UIColor in the cloud as a string (r,g,b,a)
        self.color = MYStringToColor(cloudValue);
    }
    else
    {
        return [super setLocalValueForCloudValue:cloudValue cloudKey:cloudKey];
    }
}

And that's all there is to it. You can use the same property names, even if the localValue & cloudValue have different class types. Just use the above two methods to handle any conversions you need to perform.