Files
yattee/Yattee/Core/AccountRemoval.swift
Arkadiusz Fal ccf5c51460 Keep an automatic repair from ever reaching the server
A repair carries the timestamp of the selection it replaced, which is what
stops it outranking a deliberate choice made elsewhere — but it also makes
every ordering comparison read it as that choice. A fetch holding the
selected account's tombstone and an older server context applied them in
that order: the tombstone repaired the selection, and the older context
then lost to it, so the "the server holds the stale copy" path marked the
repair dirty and uploaded an automatic fallback under the deleted choice's
timestamp, over an explicit choice. The stored context now remembers
whether it is a repair, and a repair that wins an ordering pushes nothing
back: the server's context stands remotely, the fallback stays local, and
the next explicit choice on any device re-converges everyone. The bit is
persisted with the context, or a relaunch would forget and publish it on
the first stale record it read.

Three more from the same trace:

- The repair prefers a signed-in login on the same server, decided off
  statuses, and it commits — once the selection no longer names the deleted
  account there is nothing left for a later status to move. Foreground
  activation refreshes statuses ahead of it, but a deletion delivered by a
  silent push arrives outside that sequence, and a credential that reached
  this device over iCloud Keychain while it was in the background announces
  nothing. The refresh is now asked for by the repair itself, which closes
  every entry point rather than each fetch trigger having to remember one.

- A device that meets a tombstone before the account it kills cannot record
  which instance that account lived on, and the account record arriving
  afterwards was discarded — with the only copy of that fact still in it.
  Every later repair of a context naming it then fell to local past a
  usable sibling. The association is kept as the record is dropped.

- Removing an orphaned account deleted its credential before recording the
  deletion, so a crash in between brought the account back on the next
  launch as one nobody can sign into, owing no deletion to any device. The
  outbox goes first, here and in the sign-out and instance cascades; the
  scoped credential key needs only the account's ID, and the store is
  resolved through the instance before anything is removed.
2026-09-05 23:06:10 +02:00

51 lines
2.1 KiB
Swift

//
// AccountRemoval.swift
// Yattee
//
// Taking an account off this device by hand.
//
import Foundation
/// The removal cascade for an account whose instance is gone.
///
/// Kept out of `AppEnvironment` so the ordering can be exercised directly: an
/// orphan is exactly the case where the usual lookups return nothing, and a
/// cascade that quietly skips a step leaves a secret behind with no screen left
/// to reach it from.
@MainActor
enum AccountRemoval {
/// Removes an orphaned account and the credential filed under its ID.
///
/// `AppEnvironment.accountCredentialsManager(for:)` resolves the store from
/// the account's instance, and an orphan has none — going through it would
/// return `nil` and delete nothing. Every account-keyed store is asked to
/// drop the ID instead; at most one of them holds anything, and the scoped
/// key needs only the ID. The unscoped legacy item is deliberately left
/// alone: identifying it needs the instance, which is precisely what is
/// missing.
static func removeOrphan(
_ accountID: UUID,
stores: [any AccountCredentialsManager],
accountsManager: AccountsManager,
discardData: (UUID) -> Void
) {
// The deletion goes into the durable outbox first, for the same reason
// `removeAccount` records it before anything else: a crash after the
// credential was deleted but before the tombstone existed would bring
// the account back on the next launch as one nobody can sign into, with
// no deletion owed to any other device. Nothing here needs the account
// to still exist — the scoped credential key is built from the ID alone,
// which is the whole reason an orphan is removed this way.
accountsManager.removeAccount(accountID)
for store in stores {
store.deleteCredential(forAccountID: accountID)
}
// Whatever the account had in memory goes with it, or its subscriptions
// and playlists would keep answering for an account nobody has.
discardData(accountID)
}
}