NoteSync — Architecture
CloudKit zone topology
+--------------------------------+
| Private DB (per user) |
| - Zone: "NoteSync" |
| - Note records |
| - Folder records |
+--------------------------------+
+--------------------------------+
| Shared DB (per participant) |
| - Zone: "Shared:Drafts" | <-- shared by Owner A
| - Note records |
| - CKShare record |
| - Zone: "Shared:Household" | <-- shared by Owner B
| - Note + Folder records |
| - CKShare record |
+--------------------------------+
Key fact: in CloudKit, sharing happens at the zone level. To share a single note, you create a zone for it. To share a folder of 50 notes, you create one zone holding all 50. The sharing mental model is: the share URL grants access to a zone.
So when the user picks “Share this note,” internally we move it to a new shared zone before generating the CKShare.
Module layout
NoteSync/
iOSApp/
macOSApp/
ShortcutsExtension/ # AppIntents extension
Packages/
NoteSyncCore/ # models, errors
NoteSyncCloud/ # all CloudKit code
NoteSyncSearch/ # full-text search index
NoteSyncUI/ # views shared between iOS/macOS where possible
NoteSyncIntents/ # AppIntent definitions
Data model
public struct Note: Identifiable, Codable, Equatable {
public let id: UUID
public var title: String
public var bodyMarkdown: String
public var tags: [String]
public var createdAt: Date
public var modifiedAt: Date
public var folderID: UUID?
public var lastSeenServerChangeTag: Data? // for conflict detection
}
Note is plain Swift. Persistence and CloudKit translation happens in NoteSyncCloud, not on the model.
Sync engine
A CloudKitSyncEngine actor:
- Subscribes to
CKDatabase.changesfor both Private + Shared DBs - On change, fetches deltas via
CKFetchDatabaseChangesOperation→CKFetchRecordZoneChangesOperation - Decodes each
CKRecordinto aNoteorFolder - Writes to a local SQLite store via GRDB (or SwiftData if you prefer)
- On local writes, queues a
CKModifyRecordsOperation
public actor CloudKitSyncEngine {
private let container = CKContainer(identifier: "iCloud.com.yourorg.notesync")
private var privateChangeToken: CKServerChangeToken?
private var sharedChangeToken: CKServerChangeToken?
public func sync() async throws {
try await fetchDatabaseChanges(.private)
try await fetchDatabaseChanges(.shared)
}
private func fetchDatabaseChanges(_ scope: CKDatabase.Scope) async throws {
let db = container.database(with: scope)
// CKFetchDatabaseChangesOperation -> changed zone IDs
// For each zone, CKFetchRecordZoneChangesOperation with the per-zone token
// ...
}
}
Three-way merge for conflict resolution
When CKModifyRecordsOperation returns serverRecordChanged, CloudKit gives you:
serverRecord— current server versionclientRecord— your local pending version
You also have your last successfully synced version locally (the common ancestor). With those three, you can attempt a structural merge:
public func merge(local: Note, server: Note, ancestor: Note) -> MergeOutcome {
if local.title == server.title || local.title == ancestor.title {
// Server's title wins if local didn't touch it
}
// Body merge: diff(ancestor → local) + diff(ancestor → server)
// If no overlapping ranges, apply both
// If overlap, return .conflict([local, server])
// ...
}
For text, use a line-level diff (Myers algorithm via the Difference API in Swift 5.1+). If the two patches don’t touch overlapping line ranges, apply them sequentially. If they do, surface to the user.
AppIntents
import AppIntents
public struct CreateNoteIntent: AppIntent {
public static var title: LocalizedStringResource = "Create Note"
public static var description = IntentDescription("Create a new note in NoteSync.")
@Parameter(title: "Title") public var title: String
@Parameter(title: "Body") public var body: String?
public init() {}
@MainActor
public func perform() async throws -> some IntentResult & ReturnsValue<NoteEntity> {
let note = try await NoteStore.shared.create(title: title, body: body ?? "")
return .result(value: NoteEntity(note))
}
}
public struct NoteEntity: AppEntity {
public let id: UUID
public let title: String
public static var typeDisplayRepresentation: TypeDisplayRepresentation = "Note"
public var displayRepresentation: DisplayRepresentation { .init(title: "\(title)") }
public static var defaultQuery = NoteEntityQuery()
}
public struct NoteEntityQuery: EntityQuery {
public init() {}
public func entities(for identifiers: [UUID]) async throws -> [NoteEntity] { /* ... */ }
public func suggestedEntities() async throws -> [NoteEntity] { /* ... */ }
}
ADRs
ADR-001: Native macOS, not Catalyst
We share the Swift package core, not the UI. Catalyst would be faster to build but would feel iOS-derivative on macOS; native SwiftUI on macOS produces a Mac-feeling app with sidebars, toolbars, and menu commands done right.
ADR-002: Custom sync engine, not NSPersistentCloudKitContainer
NSPersistentCloudKitContainer is the easy path but doesn’t expose conflict-resolution hooks the way raw CloudKit does. For a capstone whose point is multi-user conflict handling, we need raw control.
ADR-003: Shared zone per share, not single shared zone
CloudKit’s sharing model is zone-scoped. Putting everything in one shared zone would mean any share grants access to everything in that zone. Per-share zones give us fine-grained control.
ADR-004: GRDB for local storage, not SwiftData
GRDB’s deterministic SQLite-backed model fits our sync semantics better than SwiftData (which adds Core Data abstraction layers and obscures the change-token model we need). SwiftData would be fine for a v1; we picked GRDB for explicit control.
Threading
- Views
@MainActor. CloudKitSyncEngineis an actor.NoteSyncSearchruns on a background actor; reindex on save is fire-and-forget.
Next: Implementation guide