Workstation4 / Blog / SwiftData
SwiftData CloudKit Architecture Node.js

Why shared drives are copies, not CloudKit shares.

Ghost Car has two features that sound like the same problem but turned out not to be. Syncing your own drives across your own devices is one thing. Handing a drive to someone else is another. I solved them with two completely different mechanisms, and the reason why felt worth writing down because the instinct to reuse the first tool for the second job was strong, and wrong.

Personal sync came almost for free

I started with the easier half: getting a drive you recorded on your iPhone to show up on your iPad. With SwiftData this is close to free. You point a ModelConfiguration at a CloudKit private database and the framework handles mirroring for you. There is no upload code, no merge code, no conflict UI. The whole thing is one line in the container setup:

let modelConfiguration = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: .private("iCloud.com.hertzelle.Ghost")
)

"Almost" free is the operative word. CloudKit mirroring only works if your model plays by CloudKit's rules, and those rules are stricter than what plain SwiftData allows. Before sync behaved, I had to shape the Drive model to satisfy all of them:

  • No unique attribute constraints. CloudKit has no concept of a uniqueness guarantee, so any @Attribute(.unique) has to go. My id is a plain UUID, not a unique-constrained one.
  • Every stored property needs a default value or has to be optional. CloudKit records can arrive partially populated, so the model can never assume a value is present. In practice that meant writing var distance: Double = 0 and var endTime: Date? rather than letting properties be non-optional with no default.
  • Relationships need care. For the first pass I kept the model deliberately flat with no entity relationships at all, so there was nothing for the mirroring layer to trip over.

That last point pushed one interesting decision. A drive is really a list of GPS coordinates, which is naturally a child collection. Instead of modeling coordinates as a related entity, I store them as a single encoded blob on the Drive itself and decode on read:

var coordinatesData: Data = Data()

nonisolated var coordinates: [CLLocationCoordinate2D] {
    guard let coordData = try? JSONDecoder()
        .decode([[String: Double]].self, from: coordinatesData)
    else { return [] }
    return coordData.compactMap { dict in
        guard let lat = dict["lat"], let lon = dict["lon"] else { return nil }
        return CLLocationCoordinate2D(latitude: lat, longitude: lon)
    }
}

Collapsing the coordinates into one Data field keeps the schema a single flat record, which is exactly what CloudKit mirroring likes, and it sidesteps the question of syncing thousands of tiny child rows per drive. None of these constraints are exotic, but they fail silently. Sync does not throw when your model is wrong; it just quietly does nothing, and you are left staring at an empty iPad. Checking the model against the rules up front was much cheaper than debugging that later.

Sharing is a different question

Then came the harder half. A user records a good commute and wants to send it to a friend so the friend can race it. The instinct is to reach for the same tool: CloudKit already moves drives between devices, so why not use it to move a drive between people?

Two things gave me pause. The first is practical. SwiftData does not expose CKShare, the CloudKit primitive for sharing a record across iCloud accounts. You can do it in raw CloudKit, but not through the SwiftData layer I was already using for everything else, and bolting a parallel CloudKit stack onto the app for one feature felt like a lot of surface area for not much gain.

The second reason is the one that actually settled it, and it is about product intent rather than framework limits. A CKShare creates a live shared reference. The recipient sees your record, and if it changes, their view changes with it. That felt like the wrong fit for Ghost Car. When you send someone a drive, it seemed right that they should get their own drive: an independent record they can keep, compare against, rename, and delete without any of it reaching back to you. A drive you shared in March should not vanish from a friend's library because you cleaned out your own history in June.

The goal was for a recipient to get their own independent drive, not a live shared reference to mine. Once that was clear, fighting the framework to get a live share would have produced the wrong behavior even if it worked.

Copies through a small companion API

So I stopped trying to make one mechanism do both jobs. Personal sync stays on CloudKit. Sharing routes through a small companion service instead, a plain Node.js and Express server that does exactly one useful thing: it accepts a drive and hands back a link. The flow is deliberately boring:

  • The sender's app serializes the drive into a flat JSON shape (start time, duration, distance, average and max speed, an array of {lat, lon} points, an optional name) and POSTs it to the upload endpoint.
  • The server validates and stores it as a static <uuid>.json file, then returns a https://share.ghostcar.app/<uuid> link.
  • The recipient opens the link, the app downloads that JSON, and imports it as a fresh local record with a brand new UUID on their device.

That last detail is the whole point. On import, the app never reuses the sender's identifier. It constructs a new Drive with its own UUID and even tags the name so it reads honestly in the library:

// Create new drive (with new UUID for the copy)
var customName = driveData.customName
if customName == nil {
    customName = "Shared Drive"
} else {
    customName = "\(driveData.customName ?? "") (Shared)"
}

let drive = Drive(startTime: startTime, duration: driveData.duration,
                  distance: driveData.distance, averageSpeed: driveData.averageSpeed,
                  maxSpeed: driveData.maxSpeed, coordinates: coordinates,
                  customName: customName)
context.insert(drive)

From that point the two copies are unrelated. The recipient's drive lives in their own private store and syncs to their own devices through their own CloudKit database. The sender can delete the original and nothing happens on the other end. A copy is not a compromise here; it is the right semantics for what "share a drive" actually means.

Keeping the server boring on purpose

Because the share link is the only thing that crosses between people, I wanted the service to hold as little as possible and to forget quickly. A few decisions fell out of that:

  • Shares are ephemeral. Each uploaded file carries an expiresAt 24 hours out, and an hourly cleanup job deletes anything older than that by checking the file's modification time. The download endpoint is just static file serving, so an expired share is a clean 404 that the app translates into "this link has expired." There is no long-lived store of other people's routes sitting around.
  • Abuse has a ceiling. The upload path checks an API key, then rate-limits per device (5 per hour, 20 per day) and per IP, and caps a single request at 6 drives, 10,000 coordinates, and a few megabytes. None of this is clever, but it keeps a free, link-based feature from turning into open file hosting.
  • Inbound data is treated as hostile. Coordinates are range-checked, and any user-supplied name is stripped of angle brackets and quotes and truncated before it is written, since that string can later be rendered in a web fallback page.

The app side mirrors this with a small set of typed errors so the user sees something human. A 429 becomes "you've reached the sharing limit," a 404 on download becomes the expiry message, a 401 means the key is wrong. The transport is unremarkable HTTP and Codable structs, which is exactly what I wanted: nothing about sharing needed to be impressive, it needed to be predictable.

This also kept the CloudKit model clean. Because sharing never touches the private database, the sync model stays the lean, flat, constraint-free shape that mirroring wants. I did not have to contort the data model to serve two masters.

The bonus I did not plan for

There was an upside I backed into. Once a shared drive exists as a self-contained JSON payload sitting behind a public URL, viewing it no longer requires the app at all. The same file that feeds an import on someone's iPhone can feed a web page, with no extra export path and no second format to maintain. The universal link already falls back to a plain web page when the app is not installed, and the data is right there next to it. Choosing the simpler mechanism for the right reason quietly left the door open to a feature I had not committed to yet.

Takeaways

  • It helped me to treat "sync my data across my devices" and "share my data with someone else" as separate problems. They have different correct answers, even when one framework looks like it could do both.
  • SwiftData plus a CloudKit private database makes personal sync nearly automatic, but only if the model obeys CloudKit's constraints: no unique attributes, defaults or optionals on every property, relationships avoided or handled with care. Failures are silent, so check the model before you blame the network.
  • A live CKShare was the wrong primitive when the recipient should own an independent copy. Reaching for a copy on purpose, minting a fresh UUID on import, matched what users actually expect from "share."
  • A boring, forgetful little service, ephemeral files, rate limits, hostile-input handling, can carry a whole feature and still leave a clean path to the next one.
W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →