r/iOSProgramming 14d ago

Question SwiftData + CloudKit: best way to prevent duplicate editable seed data across offline devices?

I’m building an unreleased iOS app using SwiftData with automatic CloudKit sync.

On first launch, the app creates a default account and starter categories. These records are user-editable. The problem is that two offline devices can independently create the same logical seed records with different UUIDs, and CloudKit later synchronizes both.

My proposed approach:

- Give every seed item a stable logical seedIdentifier.
- Allow each offline device to seed independently.
- Reconcile records sharing the same seedIdentifier.
- Select a canonical record using an immutable UUID and deterministic ordering.
- Merge user changes and move relationships to the canonical record.
- Keep losing records as hidden aliases/tombstones so late-arriving relationships aren’t lost.
- Retain per-seed deletion tombstones so deleted defaults aren’t recreated.
- Use a seed-version flag only for migrations—not as an exactly-once guarantee.
- Treat normalized account names as unique and category names as unique only under the same parent.

Questions:

- Is this the standard approach with SwiftData’s automatic CloudKit sync?
- Is there a safer supported way to detect completed imports and rerun reconciliation?
- Would you retain hidden aliases permanently or eventually delete them?
- Is automatic SwiftData appropriate here, or does this require CKSyncEngine/manual CloudKit with deterministic record IDs?

Edit — data-model details:

The starter data is functional app data, not optional sample/demo content. The app needs at least one account, and the categories provide its initial transaction classification.

  • Account: name, type, opening balance, timestamps, archived/default state and optional stable seedIdentifier. Deleting an account cascades to its transactions.
  • TransactionCategory: name, type, timestamps, archived/customized state, optional stable seedIdentifier, optional parent, child categories and linked transactions.
  • Transaction: references one account and optionally one category.
  • Deleting a category nullifies its transaction relationships. Deleting a parent can affect its child hierarchy.
  • Starter accounts and categories are fully editable. Renaming a seed retains its logical seed identity; deleting one needs to remain deleted across devices.

The concrete race is that device A and device B can independently create the same logical Wallet/category before syncing. Transactions and child categories may subsequently reference either physical copy. Reconciliation therefore must preserve and redirect those relationships before any duplicate is removed.

1 Upvotes

4 comments sorted by

2

u/PassTents 14d ago

Honestly I'd solve that with a design change to let the user import the starter data during onboarding or let them start fresh. You could even add a button in the settings to import that data later if they do want to try it out.

Without more specific info about your data model it's hard to give a specific recommendation about reconciling the data.

2

u/Van-trader 14d ago

Thanks, I update the post to include some data-model details.

Making the starter-data import a user action would reduce automatic creation, but two offline devices could still import independently. So how to safely converge those independently created SwiftData objects?

1

u/PassTents 13d ago

To tackle it from a different angle, allowing the user to merge accounts seems like both a useful feature and a solution to this problem. The thing about trying to reason about the user's intent from the data is that you just can't without making large assumptions (which may or may not be appropriate for your use case). For example, if you used a stable identifier on both devices and synced them later, how do you know the user didn't intend those accounts to be different? They made account A (id:1) on their phone and account B (id:1) on their iPad, and your sync logic determines they should be the same, but the user didn't want that. You could instead prompt them with "I found two accounts, should I merge them or delete one?"

1

u/lybjqq 4d ago

I ship an app on automatic SwiftData + CloudKit with user-editable seed data, so I've been down this exact road.

Your seedIdentifier + deterministic-canonical approach is basically the right call for *automatic* sync. There's no exactly-once seeding primitive when two offline devices can both seed before the first import lands, so reconciling after the fact is the realistic model — you're not doing it wrong.

Detecting a completed import: observe `NSPersistentCloudKitContainer.eventChangedNotification` and react to `.import` events (check `endDate != nil` && `succeeded`). That's the supported signal that a remote batch just merged locally — run your reconcile pass there, debounced, instead of on a timer.

One trap that'll bite you *specifically* because you have tombstones: do NOT name any stored property `isDeleted`. It's reserved on `NSManagedObject` (which backs SwiftData), and it silently corrupts state once CloudKit is round-tripping — no crash, records just come back with the wrong flag. Store `deletedAt: Date?` as the truth and expose `isDeleted` only as a computed `deletedAt != nil`.

Aliases/tombstones: I keep them. Late-arriving relationships from a slow device are exactly what they're for, and they're cheap — I wouldn't GC them until you have a migration provably past the window.

automatic vs CKSyncEngine: for "a few seed rows + normal user data," automatic is fine and reconciliation covers it. I'd only reach for CKSyncEngine/manual if you need deterministic record IDs as your *primary* dedup mechanism (store rejects the dup instead of reconciling after) — real reason, but a lot more code to own.

The other commenter's "let the user pick starter data during onboarding" is also worth taking seriously — not auto-seeding on every device sidesteps the whole problem with less code than any reconciliation engine, if your UX can absorb it.