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:
+312
-54
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user