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
-32
View File
@@ -1,32 +0,0 @@
import Foundation
import SwiftData
/// The bundled immutable starter-split library, brought back on demand. The seeds
/// themselves fixed ULIDs, byte-canonical bundle JSON live in `SeedLibrary`;
/// editing one clones it and permanently tombstones the seed (see
/// `SyncEngine.save(split:)`). The true first-run case is handled automatically by
/// `SyncEngine.autoSeedIfEmpty`; this on-demand path (the "Add Starter Splits"
/// button) is for a user who wants the starters back after removing some.
enum SplitSeeder {
/// Add every starter split the user doesn't already have. For each seed:
/// skip if a live split with the same NAME exists a user's edited clone of
/// "Upper Body" must not be joined by a resurrected seed of the same name;
/// else if the seed was deleted (tombstoned), restore it (lifting the veto);
/// else write it fresh.
/// Seed `order` values are fixed (03); a collision with a user split's order is
/// an accepted cosmetic tie. Idempotent against double-taps and partial prior seeds.
@MainActor
static func seedDefaults(into context: ModelContext, using sync: SyncEngine) async {
let existing = (try? context.fetch(FetchDescriptor<Split>())) ?? []
let existingNames = Set(existing.map(\.name))
for seed in SeedLibrary.seeds {
if existingNames.contains(seed.doc.name) { continue }
if await sync.isTombstoned(id: seed.id) {
await sync.restoreSeed(seed)
} else {
await sync.writeSeed(seed)
}
}
}
}