Reconcile starter seeds on connect and add restore + duplicate cleanup
Seeding now covers existing installs, not just empty containers: after the same settle delay as auto-seed, connect() branches to reconcileSeeds(), driven by a pure tested planner — upgrade an older seed revision in place (safe: clone-on-edit guarantees no user content at seed ULIDs), skip up-to-date/quarantined files, respect delete-veto stubs, and write missing seeds unless a same-name legacy split exists. Settings gains Restore Starter Splits (the one deliberate veto lift, writing current bundle bytes) and a dev duplicate-cleanup tool backed by a fail-closed scanner. The HealthKit estimate now follows the clone redirect when resolving a workout's split. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
|
||||
// Pure decision logic for bringing an install's on-disk starter-split library in
|
||||
// line with the current app bundle. No I/O and no `SyncEngine` dependency, so the
|
||||
// safety-critical part is fully unit-testable in isolation: `SyncEngine.reconcileSeeds()`
|
||||
// gathers the live seed-path files, stub set, and live split names, hands each seed to
|
||||
// `SeedReconcilePlanner.decision(...)`, and executes the returned skip / upgrade / write.
|
||||
//
|
||||
// Why this exists: starter seeds ship as byte-canonical `SplitDocument` JSON under
|
||||
// FIXED ULIDs (see `SeedLibrary`). `autoSeedIfEmpty` only ever writes them into a
|
||||
// verifiably empty container, so an existing install can never pick up an upgraded
|
||||
// seed revision or a newly-shipped seed. This planner decides, per seed, whether the
|
||||
// live fixed-ULID file is up to date, stale (needs the current bundle bytes), deleted
|
||||
// (vetoed), or missing (write it).
|
||||
//
|
||||
// Safety invariant this relies on: a live file at a seed's fixed ULID can NEVER hold
|
||||
// user-authored content — every user edit forks the seed to a fresh random ULID
|
||||
// (clone-on-edit), and fixed-ULID files only exist on app versions that have
|
||||
// clone-on-edit. So overwriting a seed-path file with current bundle bytes never
|
||||
// destroys user data; at worst it upgrades an older seed revision.
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
/// Everything the planner needs to decide one seed, pre-gathered by the engine.
|
||||
struct SeedReconcileInput: Equatable {
|
||||
/// The seed's fixed ULID.
|
||||
let seedID: String
|
||||
/// The bundle seed document (canonical current content).
|
||||
let seedDoc: SplitDocument
|
||||
/// The decoded live file currently sitting at the seed's fixed path, or `nil`
|
||||
/// when no live file exists there (deleted, or never written).
|
||||
let liveDoc: SplitDocument?
|
||||
/// Whether a tombstone stub exists for this seed's id.
|
||||
let hasStub: Bool
|
||||
}
|
||||
|
||||
// MARK: - Decision
|
||||
|
||||
enum SeedReconcileDecision: Equatable {
|
||||
/// Do nothing — the reason is carried for logging and tests.
|
||||
case skip(SeedSkipReason)
|
||||
/// A live file exists at the seed path but differs from the bundle: overwrite it
|
||||
/// with the verbatim bundle bytes (an older seed revision).
|
||||
case upgrade
|
||||
/// No live file, no veto, no name collision: write the verbatim bundle bytes (a
|
||||
/// newly-shipped seed, or a pre-fixed-ULID install missing the file).
|
||||
case write
|
||||
}
|
||||
|
||||
enum SeedSkipReason: Equatable {
|
||||
/// The live file already matches the bundle seed (schemaVersion-normalized).
|
||||
case upToDate
|
||||
/// The live file was written by a newer app version — never downgrade-rewrite it.
|
||||
case quarantined
|
||||
/// No live file, but a stub exists: the user deleted this seed. Respect the veto.
|
||||
case vetoed
|
||||
/// No live file and no stub, but a live split already uses this seed's name
|
||||
/// (e.g. a legacy runtime-built or user-cloned "Upper Body"). Writing the seed
|
||||
/// would duplicate it by name.
|
||||
case nameCollision
|
||||
}
|
||||
|
||||
// MARK: - Planner
|
||||
|
||||
enum SeedReconcilePlanner {
|
||||
/// The automatic on-connect decision for one seed (see the decision table in the
|
||||
/// file header). `liveSplitNames` must be the names of every live split currently
|
||||
/// in the container/cache after reconcile — the name-collision guard consults it.
|
||||
static func decision(
|
||||
for input: SeedReconcileInput,
|
||||
liveSplitNames: Set<String>
|
||||
) -> SeedReconcileDecision {
|
||||
if let live = input.liveDoc {
|
||||
// A live file sits at the seed path. Never touch one a newer app version
|
||||
// wrote (the forward-compat quarantine), otherwise compare content.
|
||||
guard live.isReadable else { return .skip(.quarantined) }
|
||||
return semanticallyEqual(live: live, seed: input.seedDoc)
|
||||
? .skip(.upToDate)
|
||||
: .upgrade
|
||||
}
|
||||
// No live file at the seed path.
|
||||
if input.hasStub { return .skip(.vetoed) }
|
||||
if liveSplitNames.contains(input.seedDoc.name) { return .skip(.nameCollision) }
|
||||
return .write
|
||||
}
|
||||
|
||||
/// Semantic (not byte) equality of a live seed file against the bundle seed.
|
||||
///
|
||||
/// A byte compare is wrong here: a container file may have been written by an
|
||||
/// older app version, carrying extra now-removed keys and a lower `schemaVersion`.
|
||||
/// Decoding both into the current `SplitDocument` already drops unknown keys, so
|
||||
/// the only remaining artificial difference is the version stamp — normalize the
|
||||
/// live doc's `schemaVersion` to the seed's before the `==` so an older wrapper of
|
||||
/// identical content reads as up to date (no churn), while a genuine content
|
||||
/// change reads as different (upgrade).
|
||||
static func semanticallyEqual(live: SplitDocument, seed: SplitDocument) -> Bool {
|
||||
var normalized = live
|
||||
normalized.schemaVersion = seed.schemaVersion
|
||||
return normalized == seed
|
||||
}
|
||||
|
||||
/// Whether the deliberate "Restore Starter Splits" action should (re)write a seed:
|
||||
/// only when no live file sits at its path and no live split already uses its name.
|
||||
/// Unlike `decision(...)` a stub does NOT veto here — restore lifts the veto — but
|
||||
/// the engine still removes the stub as a separate execution step. A seed whose
|
||||
/// live file already exists is left for `decision(...)` to upgrade.
|
||||
static func shouldRestore(
|
||||
hasLiveFile: Bool,
|
||||
seedName: String,
|
||||
liveSplitNames: Set<String>
|
||||
) -> Bool {
|
||||
!hasLiveFile && !liveSplitNames.contains(seedName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user