A stylist counting color does not have a reliable network. The quantity on the phone still has to change. If that edit waits on iCloud, the app fails in the room where it is needed.

StylistStash is an inventory app for hairstylists on iPhone, iPad, and Mac. Products, quantities, locations, and reminders live on the device. Moving that inventory between a person’s own devices is a paid capability, through the CloudKit private database. There is no BakBeat account and no server of our own. The screen does not read CloudKit.

Path of an inventory edit
  1. Devices iPhone, iPad, Mac
  2. Local inventory default.store The file the screen reads and writes. CloudKit is off.
  3. Application Reconciler Copies records only after this process has imported.
  4. Mirror file cloud.store SwiftData’s CloudKit store. Not the inventory UI.
  5. Transport CloudKit Private database. The app does not schedule the export.
  6. Peers Other devices Each one repeats this stack in the other direction.

The inventory has to work with no connection

The product constraint is small enough to implement literally. Local inventory is the app. Sync is an upgrade. A one-time unlock keeps editing on that device and does not sync. A yearly subscription is what opens the cloud store. An active trial does not.

That split has a practical consequence at launch. StoreKit has not finished loading when the files are opened, so the process reads a flag saved from the last verified subscription. Buying or losing sync applies on the next launch. Until then, the screen is still the local file.

The engineering problem under that rule: the screen must write a store CloudKit cannot disable, while a second store is allowed to participate in CloudKit. Something in the app has to decide which edits, deletions, and missing rows are safe to copy. A missing row must not be treated as a deletion. Two edits of the same record must not be collapsed because one timestamp is later.

What we watched

We put the same inventory on an iPhone and a Mac. This was a manual session, not an automated test, and it was not timed.

  1. Both devices showed five of one item.
  2. The iPhone went into airplane mode.
  3. That item was consumed once. The iPhone showed four. The Mac still showed five.
  4. The iPhone left airplane mode. After the connection returned, both devices showed four.

In the same kind of session, with both devices already connected, other quantity changes showed up on the second device almost immediately. That is one observation. It is not a measured latency, and it is not how the system behaves on every network.

Deletions made on either device appeared on the other and stayed deleted.

We did not edit the same record on both devices before either had received the other change. That case is not part of the manual result.

Two stores

The interface writes one SwiftData store: default.store, under Application Support, opened with CloudKit disabled. That file is the inventory. If the cloud file cannot be opened, this one still opens and the screen keeps working. A failed cloud open is reported. It is not allowed to take the local inventory down with it.

A second file, cloud.store, is created only when sync was already entitled at launch. SwiftData mirrors it to the private database iCloud.com.bakbeatlabs.styliststash. The cloud container is retained for the life of the process and is not installed in the SwiftUI environment, so it cannot write the local store by sitting next to the screen.

The app copies between the two files. CloudKit copies cloud.store toward other devices. Those are different jobs. The app does not call CloudKit to push or pull inventory rows. It listens for the mirror events SwiftData already posts.

After this process has accepted an import, a local save schedules a reconcile. The changed record is copied into cloud.store, and the local checkpoint is updated. Reconciliation runs synchronously on the main actor. Save notifications generated by the reconciler are deliberately excluded from scheduling another pass, preventing a feedback loop. Coordinator tests cover this behavior. From there, export is CloudKit’s. A successful export does not itself reconcile. The other device applies the row only after its own import finishes, and only while the app is active or on the next time it becomes active. Work that arrives in the background waits.

An empty mirror is not an empty database

The dangerous moment is the first launch, or any launch that has not yet heard from iCloud. cloud.store can be empty because the file is new. It can also be empty because the import has not arrived. Those look the same on disk.

A marker from an earlier launch only means the store was initialized once. It does not mean the mirror is current. Treating either signal as “the remote database is empty” would let a device that has not caught up publish its local picture over records it has not seen.

Reconciliation stays shut until this process observes a finished, successful import for this cloud file. CloudKit events name a store by the SQLite metadata UUID, not by the filename, so an event for some other store does not count. Setup does not count. Export does not count. An import that has not finished does not count. A failed import does not count.

A later successful import reconciles again. One completed fetch is not treated as the last fetch the device will ever see.

How a change is recognized

Every synced inventory item, saved location, and location reminder carries an app-assigned recordID. That value is not SwiftData’s internal persistent id. Creating an object assigns a new id. Saving it does not. Duplicating an item on purpose assigns another one. The models do not declare the id unique. If one store somehow holds two rows with the same id, reconciliation does not choose between them. It records the duplication and leaves the rows alone.

Each version is snapshotted and hashed with SHA-256. The hash covers the fields that define the record, including quantity, notes, photo bytes, and updatedAt. Dates and floating-point numbers are hashed as their bit patterns, so formatting cannot change the fingerprint. A location reminder’s notification identifier is left out. That string belongs to the device that scheduled the notification, and copying it would let one device replace another’s pending notification.

The last agreed fingerprint is a checkpoint, stored only on the local side. Each device has its own. Checkpoints do not travel through CloudKit, so “what we last agreed” is not a remote record. A device can accept someone else’s edit when its own checkpoint still matches its own unchanged row.

updatedAt is copied with the record and included in the hash. It is not used to pick a winner. If one side still matches the checkpoint, that side has no new edit, and the other side’s version is applied even when its timestamp is older. If both sides differ from the checkpoint, neither row is overwritten. The device that noticed stores both full snapshots in a SyncConflict record: names, quantities, photos, and the rest. There is no field-by-field merge.

The tests for this use two stores with CloudKit disabled. They edit both sides, including different photo data, and assert that each store keeps its own version, that one conflict record holds both payloads, and that a second pass changes nothing. They also assert that an older updatedAt still wins when it is the only side that changed. The manual session on the iPhone and the Mac did not stage a conflict.

Deletions have to be explicit

A missing row is not a deletion. An import can arrive before the matching delete, and a row can disappear from one file without anyone deleting it. If absence deleted the other side, a lagging mirror would destroy inventory. If absence meant “put the row back,” a real deletion would come back to life.

Deleting an item writes a tombstone first: the record id, the model name, the time, and the hash of the version that was deleted. Then the row is removed. Tombstones are part of the mirrored schema, so they can ride along in cloud.store. The same path is used for saved locations and location reminders.

On the other side, the tombstone removes the live row when that row is still the deleted version, or when the checkpoint says that side has not edited since the last agreement. If the other side has a different version, the edited row stays. Both sides are kept as a conflict. A unit test deletes on one side, edits the name on the other after they had agreed, and checks that the edited name survives.

A second reconcile does not restore the deleted rows. A delete that never wrote a tombstone does not remove the surviving copy, and it does not recreate the missing one. The test for that leaves a conflict whose remote side is simply absent.

The manual session saw deletions propagate between the Mac and the iPhone and remain deleted. It did not record whether a tombstone was what crossed the network. The implementation and the in-process tests use tombstones. The devices confirm the outcome.

A failed save does not leave a half-deleted item behind for a later autosave. If the delete was updating an existing tombstone, the previous hash and time are put back. The copy shown to the user is that the changes are still there and can be saved again.

What is known, and what is not

Asserted by tests that never sign in to iCloud

The automated tests open local files, or inject mirror events. They do not talk to the private database. They assert that:

  • The local store stays available when sync is off, and when opening the cloud store throws.
  • A local-only insert and a cloud-only insert copy by recordID, including quantity.
  • A one-sided edit copies even when its timestamp is older.
  • Divergent edits, including photos, are both kept.
  • Tombstone deletes cross between the two stores and do not resurrect. A delete does not discard a conflicting edit.
  • Absence without a tombstone neither deletes nor restores.
  • Photo data survives a reconcile and a reopen of the cloud file. Notification identifiers do not count as a synced edit.
  • No reconcile runs until a finished successful import, including when a previous launch already set a bootstrap flag, and when the cloud file is empty.
  • Repeated import events before the first flush become one run. An import that arrives in the background is applied on the next foreground.
  • A thrown reconcile leaves the local row in place. A later success can copy it.

Observed on devices

Separate from those tests: five became four on an iPhone in airplane mode while the Mac stayed at five, and both showed four after the phone reconnected. Connected quantity changes appeared almost immediately, with no clock on the session. Deletions crossed and stayed deleted. None of that was a conflict test, and none of it is a benchmark.

Not established

Live export and import under packet loss, an iCloud account change, or a full zone. Simultaneous edits on two devices through the real private database. The in-process test covers that shape against two stores. It does not cover CloudKit between them. Any person choosing between saved conflict versions. That interface is not in the app.

The app also does not retry CloudKit exports itself.

Sync is a capability, not the way in

The action that matters is recording that a tube was used. That has to work in airplane mode, on a device that has never purchased sync, and on a launch where the cloud file fails to open.

Putting CloudKit under the screen would make a network service part of reading the inventory. The split costs a second file and a reconciler with an explicit idea of absence, agreement, and conflict. What it buys is that a quantity can change whether or not iCloud is in the room. The other device catches up when an import actually arrives, and only when its own copy has not moved in the meantime.

Back to case studies