mirror of
https://github.com/yattee/yattee.git
synced 2026-10-25 06:02:57 +00:00
Let an incoming context settle what a repair cannot argue with
A repair is a fallback, not a choice: it borrows the timestamp of the selection it replaced so that a later choice still beats it, and it is never published. But that borrowed rank also won arguments it had no business winning. An incoming explicit context stamped at the same instant that loses the `DataContext.tieBreakKey` tie was neither adopted — the repair was not outranked — nor pushed back, because a repair pushes nothing. Neither side moved, and the fallback stood against the server's explicit value indefinitely. The tie is not theoretical: migrated contexts are all stamped at the epoch, and a repair keeps the timestamp of whatever it replaced, so two migrated-era values meet as equals. A stored repair now never outranks an incoming context — it is replaced by whatever arrived, older or tied, and nothing is ever enqueued for that. Adopting is safe whatever the incoming value names: one naming an account a tombstone dominates has already been turned back into a repair before the comparison, so it stays unpublished and lands on the same target this device's repair did. Two more from the same trace: - Staged records are applied by category, and contexts already come last so that a repair reads the accounts and the instance a dominated record left behind rather than falling to local past a signed-in sibling. That ordering was load-bearing and unremarked; it is now written down, along with what it does not cover — a context applied in an earlier batch than the account record that says where the deleted account lived still repairs without it, which the next explicit choice anywhere settles. - An install that repaired its context before the repair bit existed reads back as having chosen it, so that one repair can still be published once. Accepted and documented at the load site: no shipped release ever persisted a data context, the window is interim builds only, and the next explicit choice heals it.
This commit is contained in:
@@ -82,8 +82,10 @@ final class AccountsManager {
|
||||
/// decides an older server copy has to be corrected. Without this the
|
||||
/// fallback would be pushed back over a deliberate choice another device
|
||||
/// made, which is exactly what not stamping it "now" is meant to prevent.
|
||||
/// Persisted beside the context, or a relaunch would forget and publish it
|
||||
/// on the next stale record it saw.
|
||||
/// It is also what makes a repair yield to any context that arrives, rather
|
||||
/// than winning on that borrowed rank and standing against the server
|
||||
/// forever. Persisted beside the context, or a relaunch would forget and
|
||||
/// publish it on the next stale record it saw.
|
||||
private(set) var storedContextIsRepair = false
|
||||
|
||||
/// Instances the user has deleted here.
|
||||
@@ -719,18 +721,30 @@ final class AccountsManager {
|
||||
return false
|
||||
}
|
||||
|
||||
// A repair never outranks a context somebody chose. It borrowed the
|
||||
// replaced selection's timestamp only so that a later choice still beats
|
||||
// it — that borrowed rank was never meant to win an argument with an
|
||||
// explicit value, by being newer or by settling a tie on
|
||||
// `DataContext.tieBreakKey`. Leaving it in place instead would keep a
|
||||
// local fallback standing against the server's value indefinitely, since
|
||||
// a repair is also never pushed back: nothing would ever resolve the
|
||||
// disagreement. Migrated contexts make the tie real rather than
|
||||
// theoretical — they are all stamped at the epoch.
|
||||
//
|
||||
// Adopting is safe whatever the incoming value says: one naming an
|
||||
// account that is gone has already been turned back into a repair above,
|
||||
// so it stays unpublished and lands on the same target this repair did.
|
||||
if storedContextIsRepair {
|
||||
adoptSyncedContext(incoming, wasRepaired: wasRepaired)
|
||||
return false
|
||||
}
|
||||
|
||||
guard storedContext.isOutranked(by: incoming) else {
|
||||
// The local choice is the newer one and survives untouched — a
|
||||
// repair that lost the ordering never gets to clear what that
|
||||
// choice still owes the server.
|
||||
// The local choice is the newer one and survives untouched, keeping
|
||||
// whatever it still owes the server.
|
||||
guard incoming.isOutranked(by: storedContext) else { return false }
|
||||
// Newer, but not a choice. A repair carries the replaced selection's
|
||||
// timestamp, so it outranks the server's older explicit value while
|
||||
// having nothing of its own to say; publishing it would push an
|
||||
// automatic fallback over that value. Nothing is uploaded here: the
|
||||
// server's context stands remotely, the repair stays local, and the
|
||||
// next explicit choice on any device re-converges everyone.
|
||||
guard !storedContextIsRepair else { return false }
|
||||
// The server holds the older copy of a deliberate choice, so it has
|
||||
// to be pushed back over it.
|
||||
pendingContextUpload = true
|
||||
save()
|
||||
return true
|
||||
@@ -1095,6 +1109,13 @@ final class AccountsManager {
|
||||
}
|
||||
|
||||
pendingContextUpload = defaults.bool(forKey: key("pendingContextUpload"))
|
||||
// An install that repaired its context before this key existed reads
|
||||
// back as "chosen", so that one repair can still be marked dirty by an
|
||||
// older incoming context and published once. Accepted: no shipped
|
||||
// release ever persisted a data context — the whole revamp ships as a
|
||||
// single release — so the window covers only interim development and
|
||||
// TestFlight builds, it is one-shot, and the next explicit choice
|
||||
// heals it.
|
||||
storedContextIsRepair = defaults.bool(forKey: key("contextIsRepair"))
|
||||
|
||||
purgeUnresolvedNamespace()
|
||||
|
||||
@@ -410,9 +410,24 @@ final class AccountSyncCoordinator {
|
||||
isApplyingStagedChanges = true
|
||||
defer { isApplyingStagedChanges = false }
|
||||
|
||||
// Tombstone-first. A deletion that arrived in a later event than the
|
||||
// Applied by category rather than in the order the records happened to
|
||||
// be fetched, because each category depends on the ones before it.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Contexts last: repairing a context naming a deleted account reads the
|
||||
// accounts and the instance a dominated record left behind, and a repair
|
||||
// evaluated before that state exists lands on local past a signed-in
|
||||
// sibling on the same server, with nothing to re-run it afterwards.
|
||||
//
|
||||
// Only within a batch, which is what a fetch delivers and what a
|
||||
// deferred activation accumulates. A context applied in an *earlier*
|
||||
// batch than the account record that says where the deleted account
|
||||
// lived still repairs without it; the next explicit choice anywhere
|
||||
// settles that, and re-repairing on every later record would undo
|
||||
// choices made in between.
|
||||
let tombstones = staged.filter { change in
|
||||
switch AccountRecordName.parse(change.recordName) {
|
||||
case .accountDeletion: return change.kind == .save
|
||||
|
||||
Reference in New Issue
Block a user