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:
2026-07-06 16:29:59 -04:00
parent 2c1e4759ae
commit 7586edd878
10 changed files with 1344 additions and 152 deletions
+235
View File
@@ -0,0 +1,235 @@
import Foundation
// Pure planning logic for the developer-facing "duplicate cleanup" tool. No I/O
// and no `SyncEngine` dependency, so it's fully unit-testable in isolation:
// `SyncEngine.scanForDuplicates()` gathers the already-decoded documents and the
// referenced-split-id set, hands them to `DuplicateCleanupPlanner.plan(...)`, and
// `SyncEngine.performCleanup(_:)` executes the resulting plan file by file.
//
// Duplicates are detected by a *content* fingerprint that deliberately ignores
// identity (ids), timestamps, and cosmetic fields two splits/workouts that were
// created independently (e.g. by a sync hiccup, a restore, or manual testing) but
// carry the same real content are duplicates even though every id differs.
// MARK: - Plan
struct DuplicateCleanupPlan: Sendable {
struct SplitGroup: Sendable, Identifiable {
/// Members that survive always non-empty when the group is emitted.
var keep: [SplitDocument]
/// Members slated for deletion always non-empty when the group is emitted.
var delete: [SplitDocument]
var id: String {
keep.isEmpty ? (delete.first?.id ?? "") : keep.map(\.id).joined(separator: ",")
}
}
struct WorkoutGroup: Sendable, Identifiable {
var keep: WorkoutDocument
var delete: [WorkoutDocument]
var id: String { keep.id }
}
var splitGroups: [SplitGroup]
var workoutGroups: [WorkoutGroup]
var isEmpty: Bool { splitGroups.isEmpty && workoutGroups.isEmpty }
/// Total number of documents this plan would delete across every group.
var deleteCount: Int {
splitGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count }
}
}
// MARK: - Planner
enum DuplicateCleanupPlanner {
/// Builds a cleanup plan from already-decoded, already-`isReadable`-filtered
/// documents. `referencedSplitIDs` must include every split id any workout
/// (readable or quarantined) points at, plus any clone-redirect targets the
/// caller (`SyncEngine.scanForDuplicates`) is responsible for assembling it.
static func plan(
splits: [SplitDocument],
workouts: [WorkoutDocument],
referencedSplitIDs: Set<String>,
isSeed: (String) -> Bool
) -> DuplicateCleanupPlan {
DuplicateCleanupPlan(
splitGroups: planSplits(splits, referencedSplitIDs: referencedSplitIDs, isSeed: isSeed),
workoutGroups: planWorkouts(workouts)
)
}
// MARK: Splits
/// Hashable projection of a `MachineSetting`. The fingerprints keep the
/// nil / empty distinction (non-machine vs. machine-with-nothing-recorded)
/// and the user-defined order, both of which are real content.
private struct MachineSettingFingerprint: Hashable {
var name: String
var value: String
}
private static func fingerprint(_ settings: [MachineSetting]?) -> [MachineSettingFingerprint]? {
settings.map { $0.map { MachineSettingFingerprint(name: $0.name, value: $0.value) } }
}
/// Content-only projection of an exercise: ignores id and order (order is
/// captured by the parent's sort, not the element itself).
private struct ExerciseFingerprint: Hashable {
var name: String
var sets: Int
var reps: Int
var weight: Int
var loadType: Int
var durationSeconds: Int
var machineSettings: [MachineSettingFingerprint]?
}
/// Content-only projection of a split: ignores id, dates, color, systemImage,
/// order, and activityType.
private struct SplitFingerprint: Hashable {
var name: String
var exercises: [ExerciseFingerprint]
}
private static func fingerprint(_ doc: SplitDocument) -> SplitFingerprint {
SplitFingerprint(
name: doc.name.trimmingCharacters(in: .whitespacesAndNewlines),
exercises: doc.exercises.sorted { $0.order < $1.order }.map {
ExerciseFingerprint(
name: $0.name, sets: $0.sets, reps: $0.reps, weight: $0.weight,
loadType: $0.loadType, durationSeconds: $0.durationSeconds,
machineSettings: fingerprint($0.machineSettings)
)
}
)
}
/// Survivor rules (safety-critical see file header):
/// 1. A member referenced by any workout is always kept.
/// 2. A member that's a bundled seed is always kept.
/// 3. If neither rule protects anyone, the lexicographically smallest id
/// (ULIDs sort chronologically) survives deterministic across devices.
/// Everyone else in the group is deleted. A group with nothing to delete
/// (e.g. every member is protected) is dropped entirely.
private static func planSplits(
_ splits: [SplitDocument],
referencedSplitIDs: Set<String>,
isSeed: (String) -> Bool
) -> [DuplicateCleanupPlan.SplitGroup] {
let grouped = Dictionary(grouping: splits, by: fingerprint)
var groups: [DuplicateCleanupPlan.SplitGroup] = []
for (_, members) in grouped where members.count > 1 {
var keep: [SplitDocument] = []
var candidates: [SplitDocument] = []
for member in members {
if referencedSplitIDs.contains(member.id) || isSeed(member.id) {
keep.append(member)
} else {
candidates.append(member)
}
}
if keep.isEmpty, let survivorID = candidates.map(\.id).min() {
keep = candidates.filter { $0.id == survivorID }
candidates.removeAll { $0.id == survivorID }
}
guard !candidates.isEmpty else { continue }
groups.append(DuplicateCleanupPlan.SplitGroup(
keep: keep.sorted { $0.id < $1.id },
delete: candidates.sorted { $0.id < $1.id }
))
}
return groups.sorted { $0.id < $1.id }
}
// MARK: Workouts
/// Content-only projection of a log: ignores id and the started/completed
/// timestamps.
private struct WorkoutLogFingerprint: Hashable {
var exerciseName: String
var order: Int
var sets: Int
var reps: Int
var weight: Int
var loadType: Int
var durationSeconds: Int
var status: String
var currentStateIndex: Int
var notes: String
var machineSettings: [MachineSettingFingerprint]?
}
/// Content-only projection of a workout: ignores id, createdAt/updatedAt,
/// startedAt/completedAt, exact start/end time (only the calendar day of
/// `start` matters), and metrics.
private struct WorkoutFingerprint: Hashable {
var splitID: String
var splitName: String
var year: Int
var month: Int
var day: Int
var status: String
var logs: [WorkoutLogFingerprint]
}
private static func fingerprint(_ doc: WorkoutDocument) -> WorkoutFingerprint {
let comps = Calendar.current.dateComponents([.year, .month, .day], from: doc.start)
return WorkoutFingerprint(
splitID: doc.splitID ?? "",
splitName: doc.splitName ?? "",
year: comps.year ?? 0,
month: comps.month ?? 0,
day: comps.day ?? 0,
status: doc.status,
logs: doc.logs.sorted { $0.order < $1.order }.map {
WorkoutLogFingerprint(
exerciseName: $0.exerciseName, order: $0.order, sets: $0.sets, reps: $0.reps,
weight: $0.weight, loadType: $0.loadType, durationSeconds: $0.durationSeconds,
status: $0.status, currentStateIndex: $0.currentStateIndex,
notes: $0.notes ?? "",
machineSettings: fingerprint($0.machineSettings)
)
}
)
}
/// Ranks a workout for survivorship lower sorts first (wins): (1) holds the
/// HealthKit link (deleting that copy would cascade-delete the real Health
/// sample), (2) has metrics at all, (3) smallest id.
private static func survivorRank(_ doc: WorkoutDocument) -> (Int, Int, String) {
(doc.metrics?.healthKitWorkoutUUID != nil ? 0 : 1,
doc.metrics != nil ? 0 : 1,
doc.id)
}
/// If any member of a duplicate group is in progress, the whole group is
/// dropped never touch an active workout, since the watch matches its live
/// run by workout id. Otherwise the survivor is picked by `survivorRank` and
/// everyone else is deleted.
private static func planWorkouts(_ workouts: [WorkoutDocument]) -> [DuplicateCleanupPlan.WorkoutGroup] {
let grouped = Dictionary(grouping: workouts, by: fingerprint)
var groups: [DuplicateCleanupPlan.WorkoutGroup] = []
for (_, members) in grouped where members.count > 1 {
if members.contains(where: { $0.status == WorkoutStatus.inProgress.rawValue }) { continue }
let ranked = members.sorted { a, b in
let ra = survivorRank(a), rb = survivorRank(b)
if ra.0 != rb.0 { return ra.0 < rb.0 }
if ra.1 != rb.1 { return ra.1 < rb.1 }
return ra.2 < rb.2
}
guard let survivor = ranked.first else { continue }
let toDelete = ranked.dropFirst().sorted { $0.id < $1.id }
groups.append(DuplicateCleanupPlan.WorkoutGroup(keep: survivor, delete: Array(toDelete)))
}
return groups.sorted { $0.id < $1.id }
}
}
+114
View File
@@ -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)
}
}
+312 -54
View File
@@ -11,8 +11,12 @@ enum ICloudStatus: Equatable {
}
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
/// the sole source of truth: every save/delete writes files only; the metadata
/// observer (and the connect-time reconcile) is the sole mutator of the cache.
/// the sole source of truth: every save/delete writes the file first, then
/// mirrors the change into the cache immediately (a same-process write doesn't
/// reliably wake the `NSMetadataQuery` observer and never does in the
/// simulator so waiting on it leaves the UI blind to the user's own action).
/// The observer and the connect-time reconcile re-apply idempotently and remain
/// the sole channel for *remote* changes.
@Observable
@MainActor
final class SyncEngine {
@@ -27,8 +31,9 @@ final class SyncEngine {
/// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still
/// holding the seed's id resolves to the live clone. In-memory only (@Observable
/// notifies on change); not persisted a seed is edited at most once before it's
/// gone, so nothing needs to survive a relaunch.
/// notifies on change); not persisted durability comes from `repointWorkouts`,
/// which rewrites `splitID` on the workout documents themselves at fork time.
/// This map only bridges views that captured the seed's id before the swap.
private(set) var cloneRedirects: [String: String] = [:]
/// Follow the redirect chain from `id` to the current live split id. A seed
@@ -154,7 +159,7 @@ final class SyncEngine {
startMonitoring(documentsURL: store.rootURL)
cleanupOldStubs()
// Off the connect path so opening the gate isn't delayed by the settle wait.
Task { await self.autoSeedIfEmpty() }
Task { await self.seedOrReconcile() }
}
/// Invoked from the connecting screen when the user chooses not to keep
@@ -205,15 +210,8 @@ final class SyncEngine {
onCacheChanged?()
}
/// Apply a workout received from the watch. iCloud Drive stays the source of
/// truth (we write the file), but we also upsert the cache directly here.
///
/// The phone's own edits drive a local view copy, so they don't need this but a
/// watch-originated change has nothing else refreshing the phone UI, and a
/// same-process file overwrite doesn't reliably wake the `NSMetadataQuery`
/// observer. Upserting the doc we just wrote keeps cache and file consistent (the
/// observer re-applies idempotently if it does fire) and lets open phone screens
/// reflect watch progress live.
/// Apply a workout received from the watch `save(workout:)` writes the file
/// and mirrors it into the cache, same as a local edit.
func ingestFromWatch(_ doc: WorkoutDocument) async {
// A workout deleted on the phone leaves a tombstone; a watch that missed the
// delete may still push its stale copy. Honor the veto never resurrect it
@@ -224,9 +222,6 @@ final class SyncEngine {
return
}
await save(workout: doc)
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
// MARK: - Public CRUD (write path: files only)
@@ -249,11 +244,12 @@ final class SyncEngine {
do {
try await store.write(doc, to: doc.relativePath)
CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
lastSyncError = nil
} catch {
report("Failed to save split", error)
}
// Cache updates reactively via the monitor.
return doc.id
}
@@ -279,6 +275,7 @@ final class SyncEngine {
try context.save()
cloneRedirects[doc.id] = clone.id
lastSyncError = nil
await repointWorkouts(from: doc.id, to: clone.id)
onCacheChanged?()
return clone.id
} catch {
@@ -287,10 +284,39 @@ final class SyncEngine {
}
}
/// Rewrite `splitID` on every workout that references `oldID`, so split lookups
/// (category grouping, add-exercise, plan mirroring, health estimates) keep
/// resolving after a seed fork durably, across relaunches, and on the watch,
/// which the in-memory `cloneRedirects` map can't reach. `splitName` stays frozen
/// at what the workout was started as, matching rename semantics for regular
/// splits. Best effort per workout: a failed rewrite is reported and the redirect
/// map still covers it for this session.
private func repointWorkouts(from oldID: String, to newID: String) async {
guard let store else { return }
let referencing = (try? context.fetch(
FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == oldID })
)) ?? []
guard !referencing.isEmpty else { return }
for workout in referencing {
var wDoc = WorkoutDocument(from: workout)
wDoc.splitID = newID
wDoc.updatedAt = Date()
do {
try await store.write(wDoc, to: wDoc.relativePath)
CacheMapper.upsertWorkout(wDoc, relativePath: wDoc.relativePath, into: context)
} catch {
report("Failed to repoint workout \(wDoc.id) at edited split", error)
}
}
do { try context.save() } catch { report("Cache save failed", error) }
}
func save(workout doc: WorkoutDocument) async {
guard let store else { return }
do {
try await store.write(doc, to: doc.relativePath)
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
lastSyncError = nil
} catch {
report("Failed to save workout", error)
@@ -302,11 +328,24 @@ final class SyncEngine {
}
func delete(split: Split) async {
await softDelete(id: split.id, kind: "split", livePath: split.jsonRelativePath)
let id = split.id, livePath = split.jsonRelativePath
await softDelete(id: id, kind: "split", livePath: livePath)
deleteCachedEntity(id: id)
saveCacheAndNotify()
}
func delete(workout: Workout) async {
await softDelete(id: workout.id, kind: "workout", livePath: workout.jsonRelativePath)
let id = workout.id, livePath = workout.jsonRelativePath
await softDelete(id: id, kind: "workout", livePath: livePath)
deleteCachedEntity(id: id)
saveCacheAndNotify()
}
/// Persist pending cache mutations and fan out the change notification the
/// tail of every local write's immediate cache mirror.
private func saveCacheAndNotify() {
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
/// Writes a tombstone stub then removes the live file. Other devices learn of
@@ -502,47 +541,266 @@ final class SyncEngine {
onCacheChanged?()
}
/// Add one seed fresh: write its verbatim bundle bytes and upsert the cache. The
/// on-demand "Add Starter Splits" path uses this for a seed that's neither present
/// nor tombstoned.
func writeSeed(_ seed: SeedLibrary.Seed) async {
guard let store else { return }
do {
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
try context.save()
lastSyncError = nil
} catch {
report("Failed to add starter split \(seed.doc.name)", error)
/// Post-connect seed maintenance, deferred off the connect path. Settles first
/// an empty or sparse listing right after connect can just mean the metadata index
/// / placeholder files haven't surfaced existing data yet then branches so the
/// two seeders can never both fire: an empty container is first-run auto-seed's
/// alone; a non-empty one is reconciled against the current bundle.
private func seedOrReconcile() async {
// Give existing files a chance to surface before deciding empty-vs-reconcile
// (the same caution `autoSeedIfEmpty` also takes internally).
try? await Task.sleep(for: .seconds(5))
guard iCloudStatus == .available else { return }
if await containerEmpty() {
await autoSeedIfEmpty()
} else {
await reconcileSeeds()
}
onCacheChanged?()
}
/// Bring a previously-deleted seed back: lift its veto stub, then write its
/// verbatim bundle bytes. The bundle is the canonical restore source (seed content
/// is immutable), so this restores from the bundle rather than reconstructing from
/// the stub the seed's stub is only ever a minimal veto marker. The stub is
/// removed first so neither a racing observer event nor a reconcile re-vetoes the
/// file we're about to write while the stub still exists.
func restoreSeed(_ seed: SeedLibrary.Seed) async {
/// Bring the on-disk seed set in line with the current bundle on a NON-empty
/// container (autoSeedIfEmpty owns the empty case). For each bundled seed, decide
/// via the pure `SeedReconcilePlanner`:
/// live file present, up to date skip
/// live file present, older revision overwrite with bundle bytes (upgrade)
/// live file present, newer app version skip (quarantine never downgrade)
/// no live file, stub present skip (user deleted it; veto stands)
/// no live file, no stub, name in use skip (don't duplicate a same-name split)
/// no live file, no stub, name free write bundle bytes
///
/// Safe even on a stale metadata index: writing bundle bytes another device also
/// wrote is a semantically empty conflict, and the stub + name guards are re-checked
/// right before each fresh write.
private func reconcileSeeds() async {
guard let store, let tombstones else { return }
do {
try await tombstones.removeStub(at: "\(seed.id).json")
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
try context.save()
lastSyncError = nil
} catch {
report("Failed to restore starter split \(seed.doc.name)", error)
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
let stubIDs = await tombstones.listStubIDs()
let liveSplitNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
var didChange = false
for seed in SeedLibrary.seeds {
// Decode the live file at the seed's fixed path, if one exists. A file that
// exists but can't be read right now (evicted + download timed out) is left
// untouched a later connect retries it.
var liveDoc: SplitDocument?
if livePaths.contains(seed.doc.relativePath) {
guard let data = try? await store.readData(from: seed.doc.relativePath),
let decoded = try? DocumentCoder.decode(SplitDocument.self, from: data) else {
continue
}
liveDoc = decoded
}
let input = SeedReconcileInput(
seedID: seed.id, seedDoc: seed.doc, liveDoc: liveDoc,
hasStub: stubIDs.contains(seed.id)
)
switch SeedReconcilePlanner.decision(for: input, liveSplitNames: liveSplitNames) {
case .skip:
continue
case .upgrade:
// Overwriting an existing seed-path file with canonical bundle bytes is
// always safe (the file can only ever be an older seed revision).
if await writeSeedBytes(seed) { didChange = true }
case .write:
// Re-check the veto and name guards against fresh state the metadata
// index may have moved since the batch was gathered.
if await tombstones.stubExists(id: seed.id) { continue }
let names = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
if names.contains(seed.doc.name) { continue }
if await writeSeedBytes(seed) { didChange = true }
}
}
if didChange {
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
onCacheChanged?()
}
/// Whether a tombstone exists for `id` (placeholder-aware). The on-demand seed
/// path checks this to restore rather than re-create a deleted seed.
func isTombstoned(id: String) async -> Bool {
guard let tombstones else { return false }
return await tombstones.stubExists(id: id)
/// Force-restore the starter library on explicit user request ("Restore Starter
/// Splits"). For each seed with no live file and no live same-name split, lift its
/// veto stub if present the ONE place the forever-veto is deliberately lifted
/// and write the CURRENT bundle bytes (not the stub's old contents; the bundle is
/// the canonical restore source). Seeds whose live file already exists are left for
/// `reconcileSeeds` to upgrade. Returns how many seeds were (re)written.
@discardableResult
func restoreSeeds() async -> Int {
guard let store, let tombstones else { return 0 }
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
var restored = 0
for seed in SeedLibrary.seeds {
let liveNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
guard SeedReconcilePlanner.shouldRestore(
hasLiveFile: livePaths.contains(seed.doc.relativePath),
seedName: seed.doc.name,
liveSplitNames: liveNames
) else { continue }
do {
// Lift the veto: drop the seed's tombstone stub if one exists, then
// write the verbatim bundle bytes.
if await tombstones.stubExists(id: seed.id) {
try await tombstones.removeStub(at: "\(seed.id).json")
}
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
restored += 1
lastSyncError = nil
} catch {
report("Failed to restore starter split \(seed.doc.name)", error)
}
}
if restored > 0 {
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
return restored
}
/// Write a seed's verbatim bundle bytes and mirror it into the cache. Returns
/// whether the write succeeded (so the caller can flush + notify once at the end).
@discardableResult
private func writeSeedBytes(_ seed: SeedLibrary.Seed) async -> Bool {
guard let store else { return false }
do {
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
lastSyncError = nil
return true
} catch {
report("Failed to reconcile starter split \(seed.doc.name)", error)
return false
}
}
// MARK: - Duplicate cleanup (dev)
enum DuplicateScanError: Error, Sendable {
case notConnected
case unreadableFiles([String])
}
/// Scans every live (non-stub) document and builds a plan for exact-content
/// duplicate splits/workouts (see `DuplicateCleanupPlanner`). Read-only no
/// files are touched. Fails closed: if ANY file can't be read or decoded, no
/// plan is produced, because an unreadable workout could reference any split
/// and silently proceeding could misjudge that split as unreferenced.
func scanForDuplicates() async throws -> DuplicateCleanupPlan {
guard let store else { throw DuplicateScanError.notConnected }
let paths = await store.list().filter { !$0.hasPrefix("Stubs/") }
var splits: [SplitDocument] = []
var workouts: [WorkoutDocument] = []
var referencedSplitIDs = Set<String>()
var failedPaths: [String] = []
for path in paths {
let data: Data
do {
data = try await store.readData(from: path)
} catch {
log.error("scanForDuplicates: read failed for \(path, privacy: .public): \(error)")
failedPaths.append(path)
continue
}
if path.hasPrefix("Splits/") {
do {
let doc = try DocumentCoder.decode(SplitDocument.self, from: data)
// Quarantined (written by a newer app version) splits are never
// judged as duplicates or deleted.
if doc.isReadable { splits.append(doc) }
} catch {
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
failedPaths.append(path)
}
} else if path.hasPrefix("Workouts/") {
do {
let doc = try DocumentCoder.decode(WorkoutDocument.self, from: data)
// A quarantined workout's splitID still protects that split from
// deletion even though the workout itself is excluded below.
if let splitID = doc.splitID {
referencedSplitIDs.insert(splitID)
referencedSplitIDs.insert(currentSplitID(for: splitID))
}
if doc.isReadable { workouts.append(doc) }
} catch {
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
failedPaths.append(path)
}
}
}
guard failedPaths.isEmpty else {
throw DuplicateScanError.unreadableFiles(failedPaths.sorted())
}
referencedSplitIDs.formUnion(cloneRedirects.values)
return DuplicateCleanupPlanner.plan(
splits: splits,
workouts: workouts,
referencedSplitIDs: referencedSplitIDs,
isSeed: SeedLibrary.isSeed(id:)
)
}
/// Executes a previously scanned plan. Every deletion is re-checked
/// immediately beforehand against the *current* cache state a watch push or
/// another device's write can land between scan and delete so nothing
/// referenced or active is ever removed even if the plan is a few seconds stale.
func performCleanup(_ plan: DuplicateCleanupPlan) async -> (splitsDeleted: Int, workoutsDeleted: Int, skipped: Int) {
var splitsDeleted = 0
var workoutsDeleted = 0
var skipped = 0
for group in plan.splitGroups {
for doc in group.delete {
let splitID = doc.id
if SeedLibrary.isSeed(id: splitID) {
skipped += 1
continue
}
let referencingWorkouts = (try? context.fetch(
FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == splitID })
)) ?? []
guard referencingWorkouts.isEmpty else {
skipped += 1
continue
}
if let cached = CacheMapper.fetchSplit(id: splitID, in: context) {
await softDelete(id: splitID, kind: "split", livePath: cached.jsonRelativePath)
} else {
await softDelete(id: splitID, kind: "split", livePath: doc.relativePath)
}
deleteCachedEntity(id: splitID)
splitsDeleted += 1
}
}
for group in plan.workoutGroups {
for doc in group.delete {
let cached = CacheMapper.fetchWorkout(id: doc.id, in: context)
if cached?.status == .inProgress {
skipped += 1
continue
}
let livePath = cached?.jsonRelativePath ?? doc.relativePath
await softDelete(id: doc.id, kind: "workout", livePath: livePath)
deleteCachedEntity(id: doc.id)
workoutsDeleted += 1
}
}
saveCacheAndNotify()
return (splitsDeleted, workoutsDeleted, skipped)
}
// MARK: - Error Reporting