Files
yattee/Yattee/Services/CloudKit/AccountSyncStore.swift
Arkadiusz Fal 923e13565f Stop a fresh login from synthesizing a phantom migrated account
Signing in wrote `usesAccountLogin`, which syncs through key-value
storage independently of the account records. A device receiving that
flag before the account synthesized a migrated account for a login that
was never legacy — and the marker arriving later deliberately retracts
nothing, so the credential-less duplicate was permanent and uploaded.
The flag is pre-revamp state that migration only reads; accounts
describe logins now. `needsCredentials` reads the account list for any
instance that has one, so the badge it drives is unaffected.

A tombstone now dominates contexts as well as accounts: an offline
device can select an account another device deleted and publish that
choice with a newer timestamp, which would have left every device
pointing at an account that is never coming back.

Cached private feeds survived an Apple-account switch two ways. A scope
this device has seen before resolves while `CredentialIdentityScope` is
still being constructed, so the change notification fires before any
observer exists — startup now compares the persisted cache owner. And a
purge asked for before the disk cache is read was undone by the load,
which now carries it.

The need to re-bootstrap after dropping records for want of a resolved
identity is persisted. The engine may already have moved its token past
those records, so a launch that lost the in-memory flag would neither
replay nor bootstrap them.
2026-08-26 08:06:52 +02:00

177 lines
6.8 KiB
Swift

//
// 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))
}
// MARK: - Identity recovery
/// Whether account records were dropped because no identity was resolved to
/// stage them under.
///
/// Deliberately not namespaced — the identity they belonged to is the one
/// thing that could not be established — and persisted, because the engine
/// may already have moved its change token past those records: a crash
/// before the identity resolved would otherwise leave them unreachable by
/// any later fetch.
var hasPendingIdentityRecovery: Bool {
get { defaults.bool(forKey: "accountSync.pendingIdentityRecovery") }
set { defaults.set(newValue, forKey: "accountSync.pendingIdentityRecovery") }
}
/// 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
}