mirror of
https://github.com/yattee/yattee.git
synced 2026-10-27 15:07:18 +00:00
Sync accounts through CloudKit records
A login created after the revamp got a random account UUID that never left the device, so its credential was keyed to an ID no other device had and the login was unusable there. Migrated accounts were fine only because their ID is deterministically the instance ID. Accounts, the permanent legacy-migration markers, the permanent deletion tombstones and the selected data context now ride the existing CKSyncEngine as four record types, with the rules the category needs: - Absence proves nothing. A fetch resumes from a change token and never enumerates the zone, so deletions are records of their own and they dominate the account wherever they are seen — including the engine's retry-as-insert path, which is how a queued offline edit would otherwise resurrect a deleted account. - Fetched records are staged durably before each delegate event returns and applied as a set, tombstones first, gated on the zone's own result rather than didFetchChanges, which fires after failed fetches too. - Nothing activates for an identity that has not bootstrapped: a device left on the previous release consumed these record types as unknown and advanced its token past them, so each identity fetches the zone once from a nil token, outside the engine's own token. - A disabled category defers records rather than discarding them; every persisted artifact is namespaced by identity, and async work is generation-checked so a fetch begun under one Apple account can never land in another's state. - Uploads are ordered: a marker never outruns the account it describes, and a context naming an account is never published before that account is server-confirmed.
This commit is contained in:
714
Yattee/Services/CloudKit/AccountSyncCoordinator.swift
Normal file
714
Yattee/Services/CloudKit/AccountSyncCoordinator.swift
Normal file
@@ -0,0 +1,714 @@
|
||||
//
|
||||
// AccountSyncCoordinator.swift
|
||||
// Yattee
|
||||
//
|
||||
// Syncs accounts, migration markers, deletion tombstones and the selected data
|
||||
// context through the existing CKSyncEngine.
|
||||
//
|
||||
|
||||
import CloudKit
|
||||
import Foundation
|
||||
|
||||
/// Drives the account category of CloudKit sync.
|
||||
///
|
||||
/// Accounts are not SwiftData models, so they do not fit the engine's
|
||||
/// model-mapping path; and they carry rules the other categories do not:
|
||||
///
|
||||
/// - **Absence proves nothing.** A fetch resumes from a change token and never
|
||||
/// enumerates the zone, so "no account record arrived" can never mean the
|
||||
/// account was deleted. Deletions are therefore permanent `AccountDeletion`
|
||||
/// records, and they dominate the account everywhere they are seen.
|
||||
/// - **Staging is durable and applied as a set**, tombstones first, only once
|
||||
/// the zone fetch has actually succeeded — see ``AccountSyncStore``.
|
||||
/// - **Nothing activates for an identity that has not bootstrapped.** A device
|
||||
/// left on the previous release consumed these record types as unknown and
|
||||
/// advanced its token past them, so the first launch that understands them
|
||||
/// fetches the zone once from a nil token, outside the engine's own token.
|
||||
/// - **Uploads are ordered.** A marker never outruns the account it describes,
|
||||
/// and a context naming an account is never published before that account is.
|
||||
@MainActor
|
||||
final class AccountSyncCoordinator {
|
||||
typealias EnqueueChanges = @MainActor ([CKSyncEngine.PendingRecordZoneChange]) -> Void
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let accountsManager: AccountsManager
|
||||
|
||||
/// The identity every persisted artifact is keyed by, or `nil` while it is
|
||||
/// still being resolved.
|
||||
private let namespaceProvider: @MainActor () -> String?
|
||||
|
||||
private let store: AccountSyncStore
|
||||
private let mapper: CloudKitRecordMapper
|
||||
private let conflictResolver: CloudKitConflictResolver
|
||||
private let zone: CKRecordZone
|
||||
|
||||
/// Whether the accounts category may activate and upload. Accounts ride the
|
||||
/// instances category (`syncInstances`) under the iCloud master toggle.
|
||||
private let isCategoryEnabled: @MainActor () -> Bool
|
||||
|
||||
/// Registers changes with the engine. `nil` while no engine exists — the
|
||||
/// dirty outbox in `AccountsManager` is the durable record, so nothing is
|
||||
/// lost by not having one.
|
||||
private var enqueueChanges: EnqueueChanges?
|
||||
|
||||
/// The private database, for the bootstrap fetch that runs outside the
|
||||
/// engine's change token.
|
||||
private weak var database: CKDatabase?
|
||||
|
||||
/// Called when an account is deleted anywhere, so its credential and stored
|
||||
/// login identifier go with it.
|
||||
var onAccountDeleted: (@MainActor (Account) -> Void)?
|
||||
|
||||
// MARK: - Identity
|
||||
|
||||
/// Bumped on every observed change of identity. Async work captures it and
|
||||
/// refuses to persist or activate results if it has moved — a fetch begun
|
||||
/// under one Apple account must never land in another's state.
|
||||
private var identityGeneration = 0
|
||||
|
||||
/// The last identity that was actually resolved, which a later `nil` does
|
||||
/// not clear — it is the other candidate owner of anything fetched while
|
||||
/// no identity was resolved.
|
||||
private var lastResolvedNamespace: String?
|
||||
|
||||
/// Set when account records had to be dropped because no identity was
|
||||
/// resolved yet. The engine's token has already moved past them, so the
|
||||
/// identity that resolves next has to bootstrap again to recover them.
|
||||
private var droppedRecordsWhileUnresolved = false
|
||||
|
||||
private var isActivating = false
|
||||
private var activationRequested = false
|
||||
|
||||
private nonisolated(unsafe) var identityObserver: NSObjectProtocol?
|
||||
private nonisolated(unsafe) var accountsObserver: NSObjectProtocol?
|
||||
private nonisolated(unsafe) var contextObserver: NSObjectProtocol?
|
||||
|
||||
/// The identity everything is namespaced by, or `nil` while unresolved.
|
||||
private var namespace: String? { namespaceProvider() }
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
convenience init(
|
||||
accountsManager: AccountsManager,
|
||||
identityScope: CredentialIdentityScope,
|
||||
store: AccountSyncStore? = nil,
|
||||
conflictResolver: CloudKitConflictResolver = CloudKitConflictResolver(),
|
||||
zone: CKRecordZone = RecordType.createZone(),
|
||||
isCategoryEnabled: @escaping @MainActor () -> Bool
|
||||
) {
|
||||
// Accounts share the credential identity namespace: the account list
|
||||
// and the credentials it points at must always describe the same Apple
|
||||
// account. Held strongly — the scope is a shared service that knows
|
||||
// nothing about this coordinator, so there is no cycle to break, and a
|
||||
// weak reference would silently turn every namespaced operation into a
|
||||
// no-op if the scope were ever the shorter-lived of the two.
|
||||
self.init(
|
||||
accountsManager: accountsManager,
|
||||
namespaceProvider: { identityScope.current },
|
||||
store: store,
|
||||
conflictResolver: conflictResolver,
|
||||
zone: zone,
|
||||
isCategoryEnabled: isCategoryEnabled
|
||||
)
|
||||
}
|
||||
|
||||
/// Designated initializer, and the seam tests use to move the identity
|
||||
/// deterministically instead of racing a real scope resolution.
|
||||
init(
|
||||
accountsManager: AccountsManager,
|
||||
namespaceProvider: @escaping @MainActor () -> String?,
|
||||
store: AccountSyncStore? = nil,
|
||||
conflictResolver: CloudKitConflictResolver = CloudKitConflictResolver(),
|
||||
zone: CKRecordZone = RecordType.createZone(),
|
||||
isCategoryEnabled: @escaping @MainActor () -> Bool
|
||||
) {
|
||||
self.accountsManager = accountsManager
|
||||
self.namespaceProvider = namespaceProvider
|
||||
self.store = store ?? AccountSyncStore()
|
||||
self.conflictResolver = conflictResolver
|
||||
self.zone = zone
|
||||
self.mapper = CloudKitRecordMapper(zone: zone)
|
||||
self.isCategoryEnabled = isCategoryEnabled
|
||||
self.lastResolvedNamespace = namespaceProvider()
|
||||
|
||||
identityObserver = NotificationCenter.default.addObserver(
|
||||
forName: .credentialIdentityScopeDidChange,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.identityDidChange() }
|
||||
}
|
||||
|
||||
// Local edits mark themselves dirty in AccountsManager; these turn that
|
||||
// into engine registrations without every call site knowing about sync.
|
||||
accountsObserver = NotificationCenter.default.addObserver(
|
||||
forName: .accountsDidChange,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.enqueueDirtyRecords() }
|
||||
}
|
||||
|
||||
contextObserver = NotificationCenter.default.addObserver(
|
||||
forName: .dataContextDidChange,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.enqueueDirtyRecords() }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
for observer in [identityObserver, accountsObserver, contextObserver] {
|
||||
if let observer { NotificationCenter.default.removeObserver(observer) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the identity moving under a running app.
|
||||
///
|
||||
/// Nothing is migrated between namespaces here: the previous identity's
|
||||
/// staging, outbox and bootstrap flag stay where they are, unread, and the
|
||||
/// new identity starts from its own.
|
||||
///
|
||||
/// Internal so tests can drive it directly rather than racing a real scope
|
||||
/// resolution.
|
||||
func identityDidChange() {
|
||||
identityGeneration += 1
|
||||
|
||||
guard let current = namespace else { return }
|
||||
let previouslyResolved = lastResolvedNamespace
|
||||
lastResolvedNamespace = current
|
||||
|
||||
// Records dropped for want of a resolved identity belonged to whichever
|
||||
// Apple account was signed in at the time, and that cannot be
|
||||
// reconstructed afterwards: a first launch and a sign-out/sign-in look
|
||||
// identical from here. Rather than guess, every identity that could have
|
||||
// owned them bootstraps again and recovers its own from its own zone. A
|
||||
// redundant full fetch is the whole cost of that; guessing wrongly would
|
||||
// show one Apple account's logins under another's.
|
||||
if droppedRecordsWhileUnresolved {
|
||||
droppedRecordsWhileUnresolved = false
|
||||
|
||||
for candidate in Set([current, previouslyResolved].compactMap { $0 }) {
|
||||
store.clearBootstrapCompletion(in: candidate)
|
||||
}
|
||||
|
||||
LoggingService.shared.logCloudKit(
|
||||
"Account records arrived before the identity resolved — bootstrapping again to recover them"
|
||||
)
|
||||
}
|
||||
|
||||
Task { await activate() }
|
||||
}
|
||||
|
||||
// MARK: - Engine attachment
|
||||
|
||||
/// Connects the coordinator to a live sync engine.
|
||||
func attach(database: CKDatabase?, enqueueChanges: @escaping EnqueueChanges) {
|
||||
self.database = database
|
||||
self.enqueueChanges = enqueueChanges
|
||||
}
|
||||
|
||||
/// Disconnects when the engine is torn down. The durable state — staging,
|
||||
/// outbox, bootstrap flag — is deliberately left untouched.
|
||||
func detach() {
|
||||
enqueueChanges = nil
|
||||
database = nil
|
||||
}
|
||||
|
||||
// MARK: - Fetching
|
||||
|
||||
/// Stages the account-category part of one fetch event.
|
||||
///
|
||||
/// Called before the delegate returns, so the staged state is never behind
|
||||
/// the engine's change token.
|
||||
func stageFetched(records: [CKRecord], deletions: [CKRecord.ID]) {
|
||||
let accountRecords = records.filter { RecordType.accountCategory.contains($0.recordType) }
|
||||
let accountDeletions = deletions.filter { AccountRecordName.isAccountCategory($0.recordName) }
|
||||
|
||||
guard !accountRecords.isEmpty || !accountDeletions.isEmpty else { return }
|
||||
|
||||
guard let namespace else {
|
||||
// Staging under a guessed identity is the one thing worse than
|
||||
// losing the records: they would be applied to, and uploaded from,
|
||||
// an account they never belonged to.
|
||||
droppedRecordsWhileUnresolved = true
|
||||
LoggingService.shared.logCloudKit(
|
||||
"Dropping \(accountRecords.count + accountDeletions.count) account records fetched before the identity resolved"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
store.stage(records: accountRecords, deletions: accountDeletions, in: namespace)
|
||||
|
||||
LoggingService.shared.logCloudKit(
|
||||
"Staged \(accountRecords.count) account records, \(accountDeletions.count) deletions"
|
||||
)
|
||||
}
|
||||
|
||||
/// Notes the outcome of a zone fetch and activates when it succeeded.
|
||||
///
|
||||
/// The zone result — not `didFetchChanges` — is the gate: that event carries
|
||||
/// only the fetch context and fires after a partial or failed zone fetch
|
||||
/// just as it does after a complete one.
|
||||
func zoneFetchCompleted(zoneID: CKRecordZone.ID, error: CKError?) async {
|
||||
guard zoneID.zoneName == zone.zoneID.zoneName else { return }
|
||||
|
||||
if let error {
|
||||
LoggingService.shared.logCloudKitError(
|
||||
"Account records stay staged: the zone fetch failed",
|
||||
error: error
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await activate()
|
||||
}
|
||||
|
||||
// MARK: - Activation
|
||||
|
||||
/// Applies everything staged, once it is allowed to.
|
||||
///
|
||||
/// Deliberately a no-op while the category is off: the records stay staged
|
||||
/// (the "deferred records" of a disabled category) rather than being
|
||||
/// discarded, because clearing engine state to refetch them later would
|
||||
/// throw away other categories' pending changes.
|
||||
func activate() async {
|
||||
// The bootstrap inside makes this suspend, and it is driven by several
|
||||
// independent signals — every zone fetch, an identity change, the
|
||||
// category being switched back on. Overlapping runs would fetch the
|
||||
// whole zone twice; a request arriving during one is replayed after it
|
||||
// rather than dropped, since it may be the one that can finally proceed.
|
||||
guard !isActivating else {
|
||||
activationRequested = true
|
||||
return
|
||||
}
|
||||
isActivating = true
|
||||
defer { isActivating = false }
|
||||
|
||||
repeat {
|
||||
activationRequested = false
|
||||
await performActivation()
|
||||
} while activationRequested
|
||||
}
|
||||
|
||||
private func performActivation() async {
|
||||
guard let namespace else { return }
|
||||
guard isCategoryEnabled() else {
|
||||
LoggingService.shared.logCloudKit("Account sync category is off — incoming records stay deferred")
|
||||
return
|
||||
}
|
||||
|
||||
await runBootstrapIfNeeded(in: namespace)
|
||||
|
||||
guard store.isBootstrapCompleted(in: namespace) else { return }
|
||||
guard namespace == self.namespace else { return }
|
||||
|
||||
applyStagedChanges(in: namespace)
|
||||
enqueueDirtyRecords()
|
||||
}
|
||||
|
||||
private func applyStagedChanges(in namespace: String) {
|
||||
let staged = store.stagedChanges(in: namespace)
|
||||
guard !staged.isEmpty else { return }
|
||||
|
||||
// Tombstone-first. A deletion that arrived in a later event than the
|
||||
// account record it kills must still be applied before it, or the
|
||||
// account becomes visible — and selectable — in between.
|
||||
let tombstones = staged.filter { change in
|
||||
switch AccountRecordName.parse(change.recordName) {
|
||||
case .accountDeletion: return change.kind == .save
|
||||
case .account: return change.kind == .delete
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
let markers = staged.filter {
|
||||
if case .legacyMigration = AccountRecordName.parse($0.recordName) { return $0.kind == .save }
|
||||
return false
|
||||
}
|
||||
let accounts = staged.filter {
|
||||
if case .account = AccountRecordName.parse($0.recordName) { return $0.kind == .save }
|
||||
return false
|
||||
}
|
||||
let contexts = staged.filter {
|
||||
if case .dataContext = AccountRecordName.parse($0.recordName) { return $0.kind == .save }
|
||||
return false
|
||||
}
|
||||
|
||||
for change in tombstones { applyTombstone(change) }
|
||||
for change in markers { applyMarker(change) }
|
||||
for change in accounts { applyAccount(change) }
|
||||
for change in contexts { applyContext(change) }
|
||||
|
||||
// Deletions of markers and tombstones are ignored on purpose: both are
|
||||
// permanent by contract, and honouring their removal would let a device
|
||||
// resynthesize an account the user deleted.
|
||||
store.clearStagedChanges(in: namespace)
|
||||
|
||||
LoggingService.shared.logCloudKit(
|
||||
"Applied account changes: \(tombstones.count) deletions, \(markers.count) markers, \(accounts.count) accounts, \(contexts.count) contexts"
|
||||
)
|
||||
}
|
||||
|
||||
private func applyTombstone(_ change: StagedAccountChange) {
|
||||
switch AccountRecordName.parse(change.recordName) {
|
||||
case let .accountDeletion(accountID):
|
||||
guard let record = change.record(),
|
||||
let id = try? mapper.toAccountDeletionID(from: record) else { return }
|
||||
let removed = accountsManager.applyRemoteTombstone(id)
|
||||
cleanUpCredentials(for: removed, accountID: accountID)
|
||||
|
||||
case let .account(accountID):
|
||||
// The record itself was deleted. Authoritative, but not durable —
|
||||
// AccountsManager keeps the tombstone queued so this device
|
||||
// publishes one.
|
||||
let removed = accountsManager.applyRemoteAccountDeletionEvent(accountID)
|
||||
cleanUpCredentials(for: removed, accountID: accountID)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanUpCredentials(for account: Account?, accountID: UUID) {
|
||||
guard let account else { return }
|
||||
onAccountDeleted?(account)
|
||||
LoggingService.shared.logCloudKit("Removed deleted account from iCloud: \(accountID)")
|
||||
}
|
||||
|
||||
private func applyMarker(_ change: StagedAccountChange) {
|
||||
guard let record = change.record(),
|
||||
let instanceID = try? mapper.toLegacyMigrationInstanceID(from: record) else { return }
|
||||
accountsManager.applySyncedMigrationMarker(instanceID: instanceID)
|
||||
}
|
||||
|
||||
private func applyAccount(_ change: StagedAccountChange) {
|
||||
guard let record = change.record() else { return }
|
||||
|
||||
let account: Account
|
||||
do {
|
||||
account = try mapper.toAccount(from: record)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Could not read an account record", error: error)
|
||||
return
|
||||
}
|
||||
|
||||
// Convergent repair. CloudKit enforces no relationship between an
|
||||
// account and its tombstone, so a device that has not fetched the
|
||||
// deletion can recreate the record. Every device that holds the
|
||||
// tombstone ignores it locally and deletes it again server-side; the
|
||||
// window is bounded by fetch propagation, not by trust.
|
||||
guard !accountsManager.isDeleted(account.id) else {
|
||||
LoggingService.shared.logCloudKit("Ignoring resurrected account \(account.id) and re-deleting it")
|
||||
enqueueChanges?([.deleteRecord(recordID(for: .account(id: account.id)))])
|
||||
return
|
||||
}
|
||||
|
||||
if accountsManager.applySyncedAccount(account) {
|
||||
// The server still holds the older version.
|
||||
enqueueChanges?([.saveRecord(recordID(for: .account(id: account.id)))])
|
||||
}
|
||||
}
|
||||
|
||||
private func applyContext(_ change: StagedAccountChange) {
|
||||
guard let record = change.record(),
|
||||
let incoming = try? mapper.toStoredDataContext(from: record) else { return }
|
||||
|
||||
if accountsManager.applySyncedContext(incoming) {
|
||||
enqueueChanges?([.saveRecord(recordID(for: .dataContext))])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
||||
/// Fetches the zone once from a nil change token, applying only account
|
||||
/// records, so an upgrade picks up everything a previous version consumed
|
||||
/// as an unknown record type.
|
||||
///
|
||||
/// Runs outside the engine's own token: the engine's state and every other
|
||||
/// category's pending changes are untouched.
|
||||
private func runBootstrapIfNeeded(in namespace: String) async {
|
||||
guard !store.isBootstrapCompleted(in: namespace) else { return }
|
||||
guard let database else { return }
|
||||
|
||||
let generation = identityGeneration
|
||||
|
||||
LoggingService.shared.logCloudKit("Bootstrapping account records with a full zone fetch")
|
||||
|
||||
// CloudKit invokes the per-record blocks on its own queue, so the
|
||||
// results are collected behind a lock rather than in captured vars.
|
||||
let collector = BootstrapCollector()
|
||||
|
||||
let configuration = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
|
||||
configuration.previousServerChangeToken = nil
|
||||
|
||||
let operation = CKFetchRecordZoneChangesOperation(
|
||||
recordZoneIDs: [zone.zoneID],
|
||||
configurationsByRecordZoneID: [zone.zoneID: configuration]
|
||||
)
|
||||
operation.fetchAllChanges = true
|
||||
|
||||
operation.recordWasChangedBlock = { _, result in
|
||||
guard case let .success(record) = result else { return }
|
||||
guard RecordType.accountCategory.contains(record.recordType) else { return }
|
||||
collector.add(record: record)
|
||||
}
|
||||
|
||||
operation.recordWithIDWasDeletedBlock = { recordID, _ in
|
||||
guard AccountRecordName.isAccountCategory(recordID.recordName) else { return }
|
||||
collector.add(deletion: recordID)
|
||||
}
|
||||
|
||||
operation.recordZoneFetchResultBlock = { _, result in
|
||||
if case let .failure(error) = result { collector.recordZoneError(error) }
|
||||
}
|
||||
|
||||
let outcome: Result<Void, Error> = await withCheckedContinuation { continuation in
|
||||
operation.fetchRecordZoneChangesResultBlock = { result in
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
database.add(operation)
|
||||
}
|
||||
|
||||
let (fetched, deleted, zoneError) = collector.results()
|
||||
|
||||
// Both results have to succeed. A partial bootstrap leaves the flag
|
||||
// unset and runs again, which is why nothing is staged until here.
|
||||
if case let .failure(error) = outcome {
|
||||
if Self.isMissingZone(error) || zoneError.map(Self.isMissingZone) == true {
|
||||
// Nothing has ever been written to this zone, so there is
|
||||
// nothing for a bootstrap to recover.
|
||||
guard identityGeneration == generation, namespace == self.namespace else { return }
|
||||
store.markBootstrapCompleted(in: namespace)
|
||||
return
|
||||
}
|
||||
LoggingService.shared.logCloudKitError("Account bootstrap fetch failed", error: error)
|
||||
return
|
||||
}
|
||||
if let zoneError {
|
||||
if Self.isMissingZone(zoneError) {
|
||||
guard identityGeneration == generation, namespace == self.namespace else { return }
|
||||
store.markBootstrapCompleted(in: namespace)
|
||||
return
|
||||
}
|
||||
LoggingService.shared.logCloudKitError("Account bootstrap zone fetch failed", error: zoneError)
|
||||
return
|
||||
}
|
||||
|
||||
// The fetch began under one identity and may be finishing under
|
||||
// another; its results belong to the identity that asked for them.
|
||||
guard identityGeneration == generation, namespace == self.namespace else {
|
||||
LoggingService.shared.logCloudKit("Discarding an account bootstrap that finished under a different Apple account")
|
||||
return
|
||||
}
|
||||
|
||||
store.stage(records: fetched, deletions: deleted, in: namespace)
|
||||
store.markBootstrapCompleted(in: namespace)
|
||||
|
||||
LoggingService.shared.logCloudKit(
|
||||
"Account bootstrap staged \(fetched.count) records, \(deleted.count) deletions"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Uploading
|
||||
|
||||
/// Registers everything the outbox still owes the server.
|
||||
///
|
||||
/// Safe to call repeatedly: the engine collapses duplicate pending changes,
|
||||
/// and each entry is cleared only by a server confirmation.
|
||||
func enqueueDirtyRecords() {
|
||||
guard let enqueueChanges, let namespace else { return }
|
||||
guard isCategoryEnabled(), store.isBootstrapCompleted(in: namespace) else { return }
|
||||
|
||||
var changes: [CKSyncEngine.PendingRecordZoneChange] = []
|
||||
|
||||
// Tombstones first, and on their own: the account record's deletion is
|
||||
// registered when the tombstone is confirmed. A deletion whose
|
||||
// tombstone never landed is exactly how a stale device resurrects an
|
||||
// account, so the durable half goes first.
|
||||
for accountID in accountsManager.pendingDeletions {
|
||||
changes.append(.saveRecord(recordID(for: .accountDeletion(accountID: accountID))))
|
||||
}
|
||||
|
||||
for accountID in accountsManager.pendingUploads {
|
||||
changes.append(.saveRecord(recordID(for: .account(id: accountID))))
|
||||
}
|
||||
|
||||
for instanceID in accountsManager.pendingMarkerUploads
|
||||
where accountsManager.canUploadMarker(instanceID: instanceID) {
|
||||
changes.append(.saveRecord(recordID(for: .legacyAccountMigration(instanceID: instanceID))))
|
||||
}
|
||||
|
||||
if accountsManager.pendingContextUpload, accountsManager.canUploadContext {
|
||||
changes.append(.saveRecord(recordID(for: .dataContext)))
|
||||
}
|
||||
|
||||
guard !changes.isEmpty else { return }
|
||||
enqueueChanges(changes)
|
||||
}
|
||||
|
||||
/// Registers every account record for upload, for a zone with nothing in it
|
||||
/// — first enable, or a zone recreated after being deleted elsewhere.
|
||||
func uploadEverything() {
|
||||
accountsManager.markAllRecordsForUpload()
|
||||
enqueueDirtyRecords()
|
||||
}
|
||||
|
||||
/// Builds the record for a pending save at send time.
|
||||
///
|
||||
/// Returning `nil` withdraws the change from the engine — used both for
|
||||
/// records that no longer exist and for records that are being *held* by the
|
||||
/// upload ordering. Holding is safe because the outbox is durable: the
|
||||
/// change is registered again as soon as what it waits on is confirmed.
|
||||
func recordToSave(named recordName: String) -> CKRecord? {
|
||||
guard let namespace, isCategoryEnabled(), store.isBootstrapCompleted(in: namespace) else { return nil }
|
||||
|
||||
switch AccountRecordName.parse(recordName) {
|
||||
case let .account(id):
|
||||
guard !accountsManager.isDeleted(id), let account = accountsManager.account(id: id) else { return nil }
|
||||
return mapper.toCKRecord(account: account)
|
||||
|
||||
case let .legacyMigration(instanceID):
|
||||
guard accountsManager.migratedInstanceIDs.contains(instanceID),
|
||||
accountsManager.canUploadMarker(instanceID: instanceID) else { return nil }
|
||||
return mapper.toCKRecord(legacyMigrationInstanceID: instanceID)
|
||||
|
||||
case let .accountDeletion(accountID):
|
||||
guard accountsManager.isDeleted(accountID) else { return nil }
|
||||
return mapper.toCKRecord(accountDeletionID: accountID)
|
||||
|
||||
case .dataContext:
|
||||
guard accountsManager.canUploadContext else { return nil }
|
||||
return mapper.toCKRecord(dataContext: accountsManager.storedContext)
|
||||
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears outbox entries the server has confirmed, and releases whatever was
|
||||
/// waiting on them.
|
||||
func didSave(records: [CKRecord]) {
|
||||
var didConfirmAccount = false
|
||||
|
||||
for record in records {
|
||||
switch AccountRecordName.parse(record.recordID.recordName) {
|
||||
case let .account(id):
|
||||
accountsManager.confirmUpload(id, updatedAt: record["updatedAt"] as? Date)
|
||||
didConfirmAccount = true
|
||||
|
||||
case let .legacyMigration(instanceID):
|
||||
accountsManager.confirmMarkerUpload(instanceID: instanceID)
|
||||
|
||||
case let .accountDeletion(accountID):
|
||||
accountsManager.confirmDeletion(accountID)
|
||||
// Only now is removing the account record safe to attempt: the
|
||||
// durable half of the deletion exists.
|
||||
enqueueChanges?([.deleteRecord(recordID(for: .account(id: accountID)))])
|
||||
|
||||
case .dataContext:
|
||||
if let changedAt = record["changedAt"] as? Date {
|
||||
accountsManager.confirmContextUpload(changedAt: changedAt)
|
||||
}
|
||||
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// A marker or a context held behind one of these accounts can go now.
|
||||
if didConfirmAccount {
|
||||
enqueueDirtyRecords()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the engine should retry a save that failed with `unknownItem`
|
||||
/// as a fresh insert.
|
||||
///
|
||||
/// It must not for an account a tombstone dominates: reinserting is exactly
|
||||
/// how a queued offline edit resurrects a deleted account.
|
||||
func shouldRetryAsInsert(recordName: String) -> Bool {
|
||||
guard case let .account(id) = AccountRecordName.parse(recordName) else { return true }
|
||||
return !accountsManager.isDeleted(id)
|
||||
}
|
||||
|
||||
/// Resolves a conflict on an account-category record, and applies the
|
||||
/// outcome locally.
|
||||
///
|
||||
/// The local apply matters here in a way it does not for the other
|
||||
/// categories: CloudKit never delivers back a record this device saved, so
|
||||
/// a conflict the server won would otherwise leave stale state on screen
|
||||
/// until some unrelated future change to the same record.
|
||||
func resolveConflict(local: CKRecord, server: CKRecord) async -> CKRecord {
|
||||
let resolved: CKRecord
|
||||
|
||||
switch server.recordType {
|
||||
case RecordType.account:
|
||||
resolved = await conflictResolver.resolveAccountConflict(local: local, server: server)
|
||||
if let account = try? mapper.toAccount(from: resolved), !accountsManager.isDeleted(account.id) {
|
||||
accountsManager.applySyncedAccount(account)
|
||||
}
|
||||
|
||||
case RecordType.dataContext:
|
||||
resolved = await conflictResolver.resolveDataContextConflict(local: local, server: server)
|
||||
if let context = try? mapper.toStoredDataContext(from: resolved) {
|
||||
accountsManager.applySyncedContext(context)
|
||||
}
|
||||
|
||||
case RecordType.legacyAccountMigration, RecordType.accountDeletion:
|
||||
resolved = await conflictResolver.resolveWriteOnceConflict(local: local, server: server)
|
||||
|
||||
default:
|
||||
resolved = server
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func recordID(for type: SyncableRecordType) -> CKRecord.ID {
|
||||
type.recordID(in: zone)
|
||||
}
|
||||
|
||||
/// Whether an error means the zone does not exist, in which case there is
|
||||
/// nothing for a bootstrap to recover and it counts as done.
|
||||
private static func isMissingZone(_ error: Error) -> Bool {
|
||||
guard let ckError = error as? CKError else { return false }
|
||||
|
||||
switch ckError.code {
|
||||
case .zoneNotFound, .userDeletedZone:
|
||||
return true
|
||||
case .partialFailure:
|
||||
let partial = ckError.partialErrorsByItemID?.values ?? [:].values
|
||||
return partial.contains { isMissingZone($0) }
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gathers a bootstrap fetch's results across CloudKit's callback queue.
|
||||
private final class BootstrapCollector: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var records: [CKRecord] = []
|
||||
private var deletions: [CKRecord.ID] = []
|
||||
private var zoneError: Error?
|
||||
|
||||
func add(record: CKRecord) {
|
||||
lock.withLock { records.append(record) }
|
||||
}
|
||||
|
||||
func add(deletion recordID: CKRecord.ID) {
|
||||
lock.withLock { deletions.append(recordID) }
|
||||
}
|
||||
|
||||
func recordZoneError(_ error: Error) {
|
||||
lock.withLock { zoneError = error }
|
||||
}
|
||||
|
||||
func results() -> ([CKRecord], [CKRecord.ID], Error?) {
|
||||
lock.withLock { (records, deletions, zoneError) }
|
||||
}
|
||||
}
|
||||
161
Yattee/Services/CloudKit/AccountSyncStore.swift
Normal file
161
Yattee/Services/CloudKit/AccountSyncStore.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
//
|
||||
// AccountSyncStore.swift
|
||||
// Yattee
|
||||
//
|
||||
// Durable, identity-namespaced staging for fetched account-category records.
|
||||
//
|
||||
|
||||
import CloudKit
|
||||
import Foundation
|
||||
|
||||
/// One fetched account-category change, held until it may be applied.
|
||||
struct StagedAccountChange: Codable, Sendable {
|
||||
enum Kind: String, Codable, Sendable {
|
||||
case save
|
||||
case delete
|
||||
}
|
||||
|
||||
/// Arrival order. Two changes to the same record collapse to the later one;
|
||||
/// changes to *different* records keep their order, which is what lets a
|
||||
/// tombstone be applied ahead of an account record it dominates.
|
||||
let sequence: Int
|
||||
let recordName: String
|
||||
let kind: Kind
|
||||
|
||||
/// Archived `CKRecord`. `nil` for deletions, which carry only a record ID.
|
||||
let recordData: Data?
|
||||
|
||||
func record() -> CKRecord? {
|
||||
guard let recordData else { return nil }
|
||||
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKRecord.self, from: recordData)
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage for everything account sync has to survive a relaunch with.
|
||||
///
|
||||
/// ### Why staging is persisted at all
|
||||
///
|
||||
/// `CKSyncEngine` emits several `fetchedRecordZoneChanges` events between
|
||||
/// `willFetchChanges` and `didFetchChanges`, and may persist its change token
|
||||
/// after any of them. Applying records as they arrive would expose an account
|
||||
/// delivered in the first event before the tombstone that deletes it arrives in
|
||||
/// the third; holding them only in memory would lose them to a crash while the
|
||||
/// engine's token had already moved past them. So each event's records are
|
||||
/// written here before the delegate returns, and applied as a set once the zone
|
||||
/// fetch has actually succeeded.
|
||||
///
|
||||
/// Durability rests on the same mechanism the engine's own token does: both are
|
||||
/// `UserDefaults` writes, and they are ordered, so any crash that preserved the
|
||||
/// token also preserved the staging written before it.
|
||||
///
|
||||
/// ### Why everything is namespaced
|
||||
///
|
||||
/// Every value here belongs to one Apple account. Sharing storage across
|
||||
/// identities would let a fetch begun under one account be applied under the
|
||||
/// next, which is precisely the disclosure that scoping accounts prevents.
|
||||
@MainActor
|
||||
final class AccountSyncStore {
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
private func key(_ name: String, in namespace: String) -> String {
|
||||
"accountSync.\(namespace).\(name)"
|
||||
}
|
||||
|
||||
// MARK: - Staging
|
||||
|
||||
func stagedChanges(in namespace: String) -> [StagedAccountChange] {
|
||||
guard let data = defaults.data(forKey: key("staged", in: namespace)),
|
||||
let decoded = try? JSONDecoder().decode([StagedAccountChange].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return decoded.sorted { $0.sequence < $1.sequence }
|
||||
}
|
||||
|
||||
/// Stages fetched changes, replacing any earlier change to the same record.
|
||||
///
|
||||
/// Records that fail to archive are dropped rather than silently staged as
|
||||
/// deletions — losing a record is recoverable by a later fetch or bootstrap,
|
||||
/// but a phantom deletion is not.
|
||||
func stage(records: [CKRecord], deletions: [CKRecord.ID], in namespace: String) {
|
||||
guard !records.isEmpty || !deletions.isEmpty else { return }
|
||||
|
||||
var byName: [String: StagedAccountChange] = [:]
|
||||
for change in stagedChanges(in: namespace) {
|
||||
byName[change.recordName] = change
|
||||
}
|
||||
|
||||
var sequence = byName.values.map(\.sequence).max().map { $0 + 1 } ?? 0
|
||||
|
||||
for record in records {
|
||||
guard let data = try? NSKeyedArchiver.archivedData(
|
||||
withRootObject: record,
|
||||
requiringSecureCoding: true
|
||||
) else {
|
||||
LoggingService.shared.logCloudKitError(
|
||||
"Could not archive account record \(record.recordID.recordName) for staging",
|
||||
error: CloudKitError.recordNotFound
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
byName[record.recordID.recordName] = StagedAccountChange(
|
||||
sequence: sequence,
|
||||
recordName: record.recordID.recordName,
|
||||
kind: .save,
|
||||
recordData: data
|
||||
)
|
||||
sequence += 1
|
||||
}
|
||||
|
||||
for recordID in deletions {
|
||||
byName[recordID.recordName] = StagedAccountChange(
|
||||
sequence: sequence,
|
||||
recordName: recordID.recordName,
|
||||
kind: .delete,
|
||||
recordData: nil
|
||||
)
|
||||
sequence += 1
|
||||
}
|
||||
|
||||
let ordered = byName.values.sorted { $0.sequence < $1.sequence }
|
||||
guard let data = try? JSONEncoder().encode(ordered) else {
|
||||
LoggingService.shared.logCloudKit("Could not persist staged account changes")
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: key("staged", in: namespace))
|
||||
}
|
||||
|
||||
func clearStagedChanges(in namespace: String) {
|
||||
defaults.removeObject(forKey: key("staged", in: namespace))
|
||||
}
|
||||
|
||||
// MARK: - Schema bootstrap
|
||||
|
||||
/// Whether this identity has run the one-time full-zone fetch that picks up
|
||||
/// account records a previous app version consumed without understanding.
|
||||
func isBootstrapCompleted(in namespace: String) -> Bool {
|
||||
defaults.integer(forKey: key("schemaVersion", in: namespace)) >= AccountSyncStore.schemaVersion
|
||||
}
|
||||
|
||||
func markBootstrapCompleted(in namespace: String) {
|
||||
defaults.set(AccountSyncStore.schemaVersion, forKey: key("schemaVersion", in: namespace))
|
||||
}
|
||||
|
||||
/// Forces the bootstrap to run again for this identity.
|
||||
///
|
||||
/// Used when account records had to be dropped because the identity they
|
||||
/// belonged to could not be established at the time — the engine's token has
|
||||
/// moved past them, so only a full-zone fetch can recover them.
|
||||
func clearBootstrapCompletion(in namespace: String) {
|
||||
defaults.removeObject(forKey: key("schemaVersion", in: namespace))
|
||||
}
|
||||
|
||||
/// The account-category schema this build understands. Raising it makes
|
||||
/// every identity bootstrap once more, which is how a future release picks
|
||||
/// up record types this one ignores.
|
||||
static let schemaVersion = 1
|
||||
}
|
||||
@@ -329,7 +329,74 @@ actor CloudKitConflictResolver {
|
||||
resolved["updatedAt"] = local["updatedAt"]
|
||||
resolved["layoutJSON"] = local["layoutJSON"]
|
||||
}
|
||||
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - Account Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server account records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Last write wins on the app-managed `updatedAt`, never on CloudKit's
|
||||
/// modification date: an edit made offline has to stay orderable against
|
||||
/// a concurrent edit elsewhere, and a migrated account carries an
|
||||
/// epoch-old date precisely so any real edit outranks it.
|
||||
/// - `instanceID` and `dateAdded` are immutable identity, taken from
|
||||
/// whichever side wins only so a malformed record cannot strip them.
|
||||
func resolveAccountConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast
|
||||
let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
let resolved = server
|
||||
|
||||
if localUpdated > serverUpdated {
|
||||
resolved["displayName"] = local["displayName"]
|
||||
resolved["updatedAt"] = local["updatedAt"]
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - DataContext Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server data-context records.
|
||||
///
|
||||
/// Strategy: last write wins on the app-managed `changedAt` — the moment
|
||||
/// the user chose, not the moment a record happened to be written.
|
||||
func resolveDataContextConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localChanged = (local["changedAt"] as? Date) ?? Date.distantPast
|
||||
let serverChanged = (server["changedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
let resolved = server
|
||||
|
||||
if localChanged > serverChanged {
|
||||
resolved["contextKind"] = local["contextKind"]
|
||||
resolved["accountID"] = local["accountID"]
|
||||
resolved["changedAt"] = local["changedAt"]
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - Write-Once Conflict Resolution
|
||||
|
||||
/// Resolves a conflict on a record that only ever means "this happened":
|
||||
/// the legacy-migration markers and the account deletion tombstones.
|
||||
///
|
||||
/// There is no state to merge — both sides assert the same fact — so the
|
||||
/// server's copy is kept, which also preserves its change tag.
|
||||
func resolveWriteOnceConflict(
|
||||
local _: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,4 +894,220 @@ final class CloudKitRecordMapper {
|
||||
layout: layout
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Account Mapping
|
||||
|
||||
/// Converts an `Account` to a CKRecord.
|
||||
///
|
||||
/// The login identifier is deliberately absent: it lives in the Keychain
|
||||
/// beside the credential, so synced metadata never names a user.
|
||||
func toCKRecord(account: Account) -> CKRecord {
|
||||
let recordID = SyncableRecordType.account(id: account.id).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.account, recordID: recordID)
|
||||
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
record["accountID"] = account.id.uuidString as CKRecordValue
|
||||
record["instanceID"] = account.instanceID.uuidString as CKRecordValue
|
||||
record["displayName"] = account.displayName as CKRecordValue?
|
||||
record["dateAdded"] = account.dateAdded as CKRecordValue
|
||||
record["updatedAt"] = account.updatedAt as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates an `Account` from a CKRecord.
|
||||
func toAccount(from record: CKRecord) throws -> Account {
|
||||
let recordType = RecordType.account
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
guard let accountIDString = record["accountID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "accountID", recordType: recordType)
|
||||
}
|
||||
guard let accountID = UUID(uuidString: accountIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "accountID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard let instanceIDString = record["instanceID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "instanceID", recordType: recordType)
|
||||
}
|
||||
guard let instanceID = UUID(uuidString: instanceIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "instanceID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard let dateAdded = record["dateAdded"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "dateAdded", recordType: recordType)
|
||||
}
|
||||
guard let updatedAt = record["updatedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
// The record name is the identity every device agrees on; a record whose
|
||||
// payload disagrees with it would resolve conflicts against a different
|
||||
// account than the one it overwrites.
|
||||
guard record.recordID.recordName == AccountRecordName.account(accountID) else {
|
||||
throw CloudKitError.typeMismatch(
|
||||
field: "accountID",
|
||||
recordType: recordType,
|
||||
expected: "value matching the record name"
|
||||
)
|
||||
}
|
||||
|
||||
return Account(
|
||||
id: accountID,
|
||||
instanceID: instanceID,
|
||||
displayName: record["displayName"] as? String,
|
||||
dateAdded: dateAdded,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Legacy Account Migration Marker Mapping
|
||||
|
||||
/// A permanent marker recording that this instance's legacy login has been
|
||||
/// turned into an account. Write-once: it carries no mutable state.
|
||||
func toCKRecord(legacyMigrationInstanceID instanceID: UUID) -> CKRecord {
|
||||
let recordID = SyncableRecordType.legacyAccountMigration(instanceID: instanceID).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.legacyAccountMigration, recordID: recordID)
|
||||
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
record["instanceID"] = instanceID.uuidString as CKRecordValue
|
||||
record["migratedAt"] = Date() as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func toLegacyMigrationInstanceID(from record: CKRecord) throws -> UUID {
|
||||
let recordType = RecordType.legacyAccountMigration
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
guard let instanceIDString = record["instanceID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "instanceID", recordType: recordType)
|
||||
}
|
||||
guard let instanceID = UUID(uuidString: instanceIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "instanceID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard record.recordID.recordName == AccountRecordName.legacyMigration(instanceID) else {
|
||||
throw CloudKitError.typeMismatch(
|
||||
field: "instanceID",
|
||||
recordType: recordType,
|
||||
expected: "value matching the record name"
|
||||
)
|
||||
}
|
||||
|
||||
return instanceID
|
||||
}
|
||||
|
||||
// MARK: - Account Deletion Tombstone Mapping
|
||||
|
||||
/// A permanent tombstone for a deleted account.
|
||||
func toCKRecord(accountDeletionID accountID: UUID, deletedAt: Date = Date()) -> CKRecord {
|
||||
let recordID = SyncableRecordType.accountDeletion(accountID: accountID).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.accountDeletion, recordID: recordID)
|
||||
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
record["accountID"] = accountID.uuidString as CKRecordValue
|
||||
record["deletedAt"] = deletedAt as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func toAccountDeletionID(from record: CKRecord) throws -> UUID {
|
||||
let recordType = RecordType.accountDeletion
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
guard let accountIDString = record["accountID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "accountID", recordType: recordType)
|
||||
}
|
||||
guard let accountID = UUID(uuidString: accountIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "accountID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard record.recordID.recordName == AccountRecordName.accountDeletion(accountID) else {
|
||||
throw CloudKitError.typeMismatch(
|
||||
field: "accountID",
|
||||
recordType: recordType,
|
||||
expected: "value matching the record name"
|
||||
)
|
||||
}
|
||||
|
||||
return accountID
|
||||
}
|
||||
|
||||
// MARK: - DataContext Mapping
|
||||
|
||||
/// The singleton record holding the selected data context.
|
||||
///
|
||||
/// `changedAt` is app-managed and is the only ordering ever used for this
|
||||
/// record — CloudKit's modification date says when a *record* was written,
|
||||
/// not when the user made a choice.
|
||||
func toCKRecord(dataContext stored: StoredDataContext) -> CKRecord {
|
||||
let recordID = SyncableRecordType.dataContext.recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.dataContext, recordID: recordID)
|
||||
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
record["changedAt"] = stored.changedAt as CKRecordValue
|
||||
|
||||
switch stored.context {
|
||||
case .local:
|
||||
record["contextKind"] = "local" as CKRecordValue
|
||||
record["accountID"] = nil
|
||||
case let .account(id):
|
||||
record["contextKind"] = "account" as CKRecordValue
|
||||
record["accountID"] = id.uuidString as CKRecordValue
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func toStoredDataContext(from record: CKRecord) throws -> StoredDataContext {
|
||||
let recordType = RecordType.dataContext
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
guard let kind = record["contextKind"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "contextKind", recordType: recordType)
|
||||
}
|
||||
guard let changedAt = record["changedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "changedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "local":
|
||||
return StoredDataContext(context: .local, changedAt: changedAt)
|
||||
case "account":
|
||||
guard let accountIDString = record["accountID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "accountID", recordType: recordType)
|
||||
}
|
||||
guard let accountID = UUID(uuidString: accountIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "accountID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
return StoredDataContext(context: .account(accountID), changedAt: changedAt)
|
||||
default:
|
||||
throw CloudKitError.typeMismatch(field: "contextKind", recordType: recordType, expected: "\"local\" or \"account\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,11 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
/// Player controls layout service for importing/removing presets from CloudKit.
|
||||
/// Set after initialization to use shared instance instead of creating new ones.
|
||||
weak var playerControlsLayoutService: PlayerControlsLayoutService?
|
||||
|
||||
/// Drives the account category: accounts, migration markers, deletion
|
||||
/// tombstones and the selected data context. Set after initialization, and
|
||||
/// nil in tests that only exercise the SwiftData categories.
|
||||
var accountSync: AccountSyncCoordinator?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -303,6 +308,10 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
conflictResolvedRecords.removeAll()
|
||||
retryCount.removeAll()
|
||||
deferredPlaylistItems.removeAll()
|
||||
// The account outbox, staging and bootstrap flag are deliberately left
|
||||
// alone: they are the durable record of what still has to happen, and
|
||||
// re-enabling sync must resume rather than start over.
|
||||
accountSync?.detach()
|
||||
syncEngine = nil
|
||||
updatePendingCount()
|
||||
isSyncing = false
|
||||
@@ -386,6 +395,15 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
pendingChangesBuffer.removeAll()
|
||||
}
|
||||
|
||||
// Accounts activate before the first sync: the bootstrap fetch has
|
||||
// to complete before anything is uploaded, or a device upgrading
|
||||
// from the previous release would publish its unmerged local list
|
||||
// over records it has not seen yet.
|
||||
accountSync?.attach(database: database) { [weak self] changes in
|
||||
self?.addPendingChanges(changes)
|
||||
}
|
||||
await accountSync?.activate()
|
||||
|
||||
if accountChanged {
|
||||
LoggingService.shared.logCloudKit("CloudKit sync engine initialized with new account (fresh sync state)")
|
||||
} else {
|
||||
@@ -560,6 +578,12 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
private var canSyncControlsPresets: Bool {
|
||||
isSyncEnabled && (settingsManager?.syncSettings ?? true)
|
||||
}
|
||||
|
||||
/// Whether account sync is enabled. Accounts ride the instances category:
|
||||
/// they describe logins on those instances, and the two are useless apart.
|
||||
var canSyncAccounts: Bool {
|
||||
isSyncEnabled && (settingsManager?.syncInstances ?? true)
|
||||
}
|
||||
|
||||
/// Manually trigger a full sync.
|
||||
func sync() async {
|
||||
@@ -1534,7 +1558,20 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
}
|
||||
|
||||
// MARK: - Initial Upload Coordination
|
||||
|
||||
|
||||
/// Re-registers every account record for upload.
|
||||
///
|
||||
/// Used when the zone is being seeded or reseeded — enabling sync, or
|
||||
/// recreating the zone after it was deleted elsewhere. The account outbox
|
||||
/// normally tracks only what changed, which is not enough when the server
|
||||
/// has nothing at all.
|
||||
func uploadAllAccounts() {
|
||||
guard canSyncAccounts else { return }
|
||||
uploadProgress?.currentCategory = "Accounts"
|
||||
accountSync?.uploadEverything()
|
||||
}
|
||||
|
||||
|
||||
/// Performs full initial upload of all local data with progress tracking
|
||||
func performInitialUpload() async {
|
||||
guard isSyncEnabled else { return }
|
||||
@@ -1555,7 +1592,8 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
await uploadAllRecentPlaylists()
|
||||
await uploadAllLocalChannelNotificationSettings()
|
||||
await uploadAllLocalControlsPresets()
|
||||
|
||||
uploadAllAccounts()
|
||||
|
||||
// Mark complete
|
||||
uploadProgress = UploadProgress(
|
||||
currentCategory: "Complete",
|
||||
@@ -1650,6 +1688,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
case .fetchedDatabaseChanges(let changes):
|
||||
await handleFetchedDatabaseChanges(changes)
|
||||
|
||||
case .didFetchRecordZoneChanges(let changes):
|
||||
// The zone's own result — not didFetchChanges, which carries only
|
||||
// the fetch context and fires after failed zone fetches too — is
|
||||
// what tells staged account records they may be applied.
|
||||
await accountSync?.zoneFetchCompleted(zoneID: changes.zoneID, error: changes.error)
|
||||
|
||||
default:
|
||||
await MainActor.run {
|
||||
switch event {
|
||||
@@ -1674,7 +1718,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
LoggingService.shared.logCloudKit("CKSyncEngine finished fetching changes")
|
||||
isReceivingChanges = false
|
||||
|
||||
case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willSendChanges, .didSendChanges, .sentDatabaseChanges:
|
||||
case .willFetchRecordZoneChanges, .willSendChanges, .didSendChanges, .sentDatabaseChanges:
|
||||
break
|
||||
|
||||
default:
|
||||
@@ -1767,6 +1811,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
/// matched on the full scoped record name (a mismatch means it belongs to
|
||||
/// a different source scope).
|
||||
private func materializeRecord(named recordName: String) async -> CKRecord? {
|
||||
// Accounts are not SwiftData models and are built from the accounts
|
||||
// store, which also decides whether a record may be sent yet.
|
||||
if AccountRecordName.isAccountCategory(recordName) {
|
||||
return accountSync?.recordToSave(named: recordName)
|
||||
}
|
||||
|
||||
guard let dataManager else { return nil }
|
||||
|
||||
if recordName.hasPrefix("sub-") {
|
||||
@@ -1849,6 +1899,10 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
retryCount.removeValue(forKey: recordName)
|
||||
}
|
||||
|
||||
// Clears the account outbox and releases whatever its ordering held
|
||||
// back. Only a confirmation does this — never the attempt.
|
||||
accountSync?.didSave(records: changes.savedRecords)
|
||||
|
||||
for failedSave in changes.failedRecordSaves {
|
||||
await handleFailedRecordSave(failedSave)
|
||||
}
|
||||
@@ -1902,6 +1956,15 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
// Record vanished server-side while we held its change tag —
|
||||
// drop the cached record and retry as a fresh insert.
|
||||
conflictResolvedRecords.removeValue(forKey: recordName)
|
||||
|
||||
// Unless a deletion tombstone dominates it. Reinserting an account
|
||||
// another device deleted is exactly how a queued offline edit
|
||||
// resurrects it.
|
||||
if let accountSync, !accountSync.shouldRetryAsInsert(recordName: recordName) {
|
||||
LoggingService.shared.logCloudKit("Record \(recordName) is deleted, not reinserting")
|
||||
return
|
||||
}
|
||||
|
||||
syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
|
||||
LoggingService.shared.logCloudKit("Record \(recordName) missing on server, retrying as insert")
|
||||
|
||||
@@ -1966,6 +2029,10 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
return await conflictResolver.resolveRecentPlaylistConflict(local: local, server: server)
|
||||
case RecordType.controlsPreset:
|
||||
return await conflictResolver.resolveLayoutPresetConflict(local: local, server: server)
|
||||
case RecordType.account, RecordType.dataContext,
|
||||
RecordType.legacyAccountMigration, RecordType.accountDeletion:
|
||||
guard let accountSync else { return server }
|
||||
return await accountSync.resolveConflict(local: local, server: server)
|
||||
default:
|
||||
// Unknown type - use server version (safe fallback)
|
||||
LoggingService.shared.logCloudKit("Unknown record type \(server.recordType), using server version")
|
||||
@@ -2002,6 +2069,15 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
/// Handle fetched changes from CloudKit.
|
||||
private func handleFetchedChanges(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) async {
|
||||
// Staged first, and before any early return: the engine may persist its
|
||||
// change token after this event, so anything not written down here is
|
||||
// gone for good. Account records are never applied inline — they are
|
||||
// applied as a set, tombstones first, once the zone fetch succeeds.
|
||||
accountSync?.stageFetched(
|
||||
records: changes.modifications.map(\.record),
|
||||
deletions: changes.deletions.map(\.recordID)
|
||||
)
|
||||
|
||||
guard let dataManager else { return }
|
||||
|
||||
// Load persisted deferred items from previous sessions
|
||||
@@ -2012,6 +2088,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
// Apply modifications - process all incoming records
|
||||
for modification in changes.modifications {
|
||||
guard !RecordType.accountCategory.contains(modification.record.recordType) else { continue }
|
||||
let result = await applyRemoteRecord(modification.record, to: dataManager)
|
||||
if case .deferred(let playlistID, let itemID) = result {
|
||||
newlyDeferredItems.append((modification.record, playlistID, itemID))
|
||||
@@ -2047,6 +2124,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
// Apply deletions
|
||||
for deletion in changes.deletions {
|
||||
guard !AccountRecordName.isAccountCategory(deletion.recordID.recordName) else { continue }
|
||||
await applyRemoteDeletion(deletion.recordID, to: dataManager)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,31 @@ enum RecordType {
|
||||
static let mediaSource = "MediaSource"
|
||||
static let channelNotificationSettings = "ChannelNotificationSettings"
|
||||
static let controlsPreset = "ControlsPreset"
|
||||
|
||||
|
||||
// MARK: - Account category
|
||||
|
||||
/// A sign-in on a backend instance (`Account`).
|
||||
static let account = "Account"
|
||||
|
||||
/// Permanent marker recording that a device turned an instance's legacy
|
||||
/// single login into an account. Never deleted: `Instance.usesAccountLogin`
|
||||
/// stays true forever, so the marker — not the account's existence — is what
|
||||
/// stops a deliberately deleted account from being synthesized again.
|
||||
static let legacyAccountMigration = "LegacyAccountMigration"
|
||||
|
||||
/// Singleton record holding the selected `DataContext`.
|
||||
static let dataContext = "DataContext"
|
||||
|
||||
/// Permanent tombstone for a deleted account. A CloudKit record deletion is
|
||||
/// not durable evidence — a device that never fetched the deletion can
|
||||
/// recreate the record from a queued offline edit — so deletions are
|
||||
/// recorded as records of their own.
|
||||
static let accountDeletion = "AccountDeletion"
|
||||
|
||||
/// Every record type in the account category, in the order a fetch applies
|
||||
/// them: tombstones dominate accounts, and markers gate synthesis.
|
||||
static let accountCategory = [accountDeletion, legacyAccountMigration, account, dataContext]
|
||||
|
||||
/// CloudKit zone name for all user data.
|
||||
static let zoneName = "UserData"
|
||||
|
||||
@@ -79,7 +103,11 @@ enum SyncableRecordType: Sendable {
|
||||
case mediaSource(id: UUID)
|
||||
case channelNotificationSettings(channelID: String, scope: SourceScope)
|
||||
case controlsPreset(id: UUID)
|
||||
|
||||
case account(id: UUID)
|
||||
case legacyAccountMigration(instanceID: UUID)
|
||||
case dataContext
|
||||
case accountDeletion(accountID: UUID)
|
||||
|
||||
/// The CloudKit record type name.
|
||||
var recordTypeName: String {
|
||||
switch self {
|
||||
@@ -96,6 +124,10 @@ enum SyncableRecordType: Sendable {
|
||||
case .mediaSource: RecordType.mediaSource
|
||||
case .channelNotificationSettings: RecordType.channelNotificationSettings
|
||||
case .controlsPreset: RecordType.controlsPreset
|
||||
case .account: RecordType.account
|
||||
case .legacyAccountMigration: RecordType.legacyAccountMigration
|
||||
case .dataContext: RecordType.dataContext
|
||||
case .accountDeletion: RecordType.accountDeletion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,12 +172,77 @@ enum SyncableRecordType: Sendable {
|
||||
recordName = "channel-notif-\(channelID)\(scope.recordNameSuffix)"
|
||||
case .controlsPreset(let id):
|
||||
recordName = "controls-\(id.uuidString)"
|
||||
case .account(let id):
|
||||
recordName = AccountRecordName.account(id)
|
||||
case .legacyAccountMigration(let instanceID):
|
||||
recordName = AccountRecordName.legacyMigration(instanceID)
|
||||
case .dataContext:
|
||||
recordName = AccountRecordName.dataContext
|
||||
case .accountDeletion(let accountID):
|
||||
recordName = AccountRecordName.accountDeletion(accountID)
|
||||
}
|
||||
|
||||
return CKRecord.ID(recordName: recordName, zoneID: zone.zoneID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record names for the account category, and the parsing that recognises them.
|
||||
///
|
||||
/// Account records are identified by name rather than by fetched record type so
|
||||
/// that a *deletion* — which carries only a record ID — can be routed to the
|
||||
/// same handler as a modification.
|
||||
enum AccountRecordName {
|
||||
static let accountPrefix = "account-"
|
||||
static let legacyMigrationPrefix = "legacymigrated-"
|
||||
static let accountDeletionPrefix = "accountDeletion-"
|
||||
static let dataContext = "datacontext-current"
|
||||
|
||||
static func account(_ id: UUID) -> String { accountPrefix + id.uuidString }
|
||||
static func legacyMigration(_ instanceID: UUID) -> String { legacyMigrationPrefix + instanceID.uuidString }
|
||||
static func accountDeletion(_ accountID: UUID) -> String { accountDeletionPrefix + accountID.uuidString }
|
||||
|
||||
/// What a record name in the account category refers to.
|
||||
enum Parsed: Hashable, Sendable {
|
||||
case account(UUID)
|
||||
case legacyMigration(instanceID: UUID)
|
||||
case dataContext
|
||||
case accountDeletion(accountID: UUID)
|
||||
}
|
||||
|
||||
/// `nil` for any name outside the account category.
|
||||
///
|
||||
/// The deletion prefix is tested first only for readability — `"account-"`
|
||||
/// cannot match `"accountDeletion-…"`, whose eighth character is `D` — but
|
||||
/// the ordering makes the intent explicit rather than relying on that.
|
||||
static func parse(_ recordName: String) -> Parsed? {
|
||||
if recordName == dataContext { return .dataContext }
|
||||
|
||||
if recordName.hasPrefix(accountDeletionPrefix) {
|
||||
guard let id = UUID(uuidString: String(recordName.dropFirst(accountDeletionPrefix.count))) else { return nil }
|
||||
return .accountDeletion(accountID: id)
|
||||
}
|
||||
if recordName.hasPrefix(legacyMigrationPrefix) {
|
||||
guard let id = UUID(uuidString: String(recordName.dropFirst(legacyMigrationPrefix.count))) else { return nil }
|
||||
return .legacyMigration(instanceID: id)
|
||||
}
|
||||
if recordName.hasPrefix(accountPrefix) {
|
||||
guard let id = UUID(uuidString: String(recordName.dropFirst(accountPrefix.count))) else { return nil }
|
||||
return .account(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Whether a record name belongs to the account category at all, including
|
||||
/// names whose UUID does not parse — those must still be kept away from the
|
||||
/// other categories' handlers, which would misread them.
|
||||
static func isAccountCategory(_ recordName: String) -> Bool {
|
||||
recordName == dataContext
|
||||
|| recordName.hasPrefix(accountDeletionPrefix)
|
||||
|| recordName.hasPrefix(legacyMigrationPrefix)
|
||||
|| recordName.hasPrefix(accountPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync operation type.
|
||||
enum SyncOperation: Sendable {
|
||||
case save
|
||||
|
||||
Reference in New Issue
Block a user