Make a second login on an instance reachable and orderable

An additional login could be created but never selected: the legacy
picker lists one entry per instance and always resolved to whichever
account signed in first. Account rows gain a "Use This Account" action,
and the subscriptions view reloads on the data context rather than on
the legacy setting — two logins on the same instance map onto the same
legacy value, so switching between them changed nothing on screen while
writes went to the other account.

Two devices migrating different legacy selections produced contexts with
identical epoch timestamps, and each refused the other's: a permanent,
silent divergence that both sides considered settled. Equal timestamps
are now broken by the selected account's ID, on the incoming side and in
the conflict resolver alike — a record that wins the upload must not be
refused on arrival.

Credential cleanup falls back to both managers whenever the backend
cannot be resolved, not only when the account is unknown here: an
instance deletion can arrive before the tombstone, and the credential
was then left in the Keychain.
This commit is contained in:
Arkadiusz Fal
2026-08-26 22:59:52 +02:00
parent 4f617b52e1
commit ae2fee2d4c
8 changed files with 104 additions and 16 deletions

View File

@@ -267,6 +267,28 @@ struct AccountSyncTests {
#expect(try mapper.toStoredDataContext(from: serverWins).context == .account(accountID))
}
@Test("Two migrated contexts converge instead of diverging")
func equalTimestampsBreakTheTieTheSameWayEverywhere() async throws {
let mapper = AccountSyncTests.makeMapper()
let resolver = CloudKitConflictResolver()
// Every migrated context is stamped at the epoch, so two devices
// migrating different legacy selections tie exactly.
let lower = StoredDataContext.migrated(.account(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!))
let higher = StoredDataContext.migrated(.account(UUID(uuidString: "FFFFFFFF-0000-0000-0000-000000000001")!))
#expect(lower.isOutranked(by: higher))
#expect(!higher.isOutranked(by: lower))
// The upload side has to reach the same verdict, or a record could
// win the conflict and then be refused when it arrives.
let resolved = await resolver.resolveDataContextConflict(
local: mapper.toCKRecord(dataContext: higher),
server: mapper.toCKRecord(dataContext: lower)
)
#expect(try mapper.toStoredDataContext(from: resolved).context == higher.context)
}
@Test("Write-once records resolve to the server copy")
func writeOnceResolvesToServer() async {
let mapper = AccountSyncTests.makeMapper()