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.changes for both Private + Shared DBs
  • On change, fetches deltas via CKFetchDatabaseChangesOperationCKFetchRecordZoneChangesOperation
  • Decodes each CKRecord into a Note or Folder
  • 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 version
  • clientRecord — 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.
  • CloudKitSyncEngine is an actor.
  • NoteSyncSearch runs on a background actor; reindex on save is fire-and-forget.

Next: Implementation guide