Files
workouts/Workouts/Sync/SyncEngine.swift
T
rzen 669ecf1259 Surface machine settings in the exercise library and refine machine rigs
Library detail screens now lead with the user's recorded machine settings
(per-split when they disagree, empty-state card for machine-based entries)
and append the weight progression chart. Starter seeds mark machine
exercises with an empty machineSettings list so the settings UI lights up
before first use. The figure rig gains a frontal body profile for face-on
machines, props that can ride mid joints (knees/elbows), and an
alternating four-frame Bird Dog loop.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
2026-07-06 18:35:15 -04:00

834 lines
38 KiB
Swift

import Foundation
import IndieSync
import SwiftData
import Observation
import os
enum ICloudStatus: Equatable {
case checking
case available
case unavailable
}
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
/// 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 {
nonisolated static let containerIdentifier = "iCloud.dev.rzen.indie.Workouts"
private(set) var iCloudStatus: ICloudStatus = .checking
private(set) var isSyncing = false
/// Last non-fatal sync failure, surfaced for the UI to render. Cleared on the
/// next successful write or full reconcile.
private(set) var lastSyncError: String?
/// 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 — 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
/// redirects at most once, but the loop tolerates (and breaks) any chain or cycle.
func currentSplitID(for id: String) -> String {
var current = id
var seen: Set<String> = [current]
while let next = cloneRedirects[current], seen.insert(next).inserted {
current = next
}
return current
}
/// Called after the cache changes (local or remote). The watch bridge uses
/// this to push fresh state to the watch.
var onCacheChanged: (() -> Void)?
/// Called when a completed workout is persisted. The HealthKit writer uses this to
/// schedule an estimated Health workout if nothing recorded it.
var onWorkoutCompleted: ((WorkoutDocument) -> Void)?
/// Called when a workout carrying watch-recorded metrics is persisted, so the
/// writer can cancel any pending phone estimate for it.
var onWatchMetricsArrived: ((String) -> Void)?
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
private let modelContainer: ModelContainer
private var store: DocumentFileStore?
private var tombstones: TombstoneStore?
private var monitor: MetadataObserver?
private var monitorTask: Task<Void, Never>?
private var connectAttempt = 0
/// How long `connect()` keeps polling for a still-provisioning iCloud
/// container before falling to the end-of-the-line gate. Deliberately long
/// (~10 min): as long as the user is signed into iCloud, a container that's
/// slow to come online should never be misreported as unavailable. Impatient
/// users bail sooner via the connecting screen's escape hatch (28s).
private static let connectTimeoutSeconds: TimeInterval = 600
private var context: ModelContext { modelContainer.mainContext }
init(container: ModelContainer) {
self.modelContainer = container
}
// MARK: - Connection (deferred, patient)
func connect() async {
guard iCloudStatus != .available else { return }
connectAttempt += 1
let attempt = connectAttempt
iCloudStatus = .checking
log.info("connect[\(attempt)]: resolving container \(Self.containerIdentifier, privacy: .public)")
// Definitive failure first: if the user isn't signed into iCloud at all,
// no container is ever coming — go straight to the end-of-the-line gate
// rather than spinning. (Signed-in-but-Drive-off still reports a token;
// that case falls through to the patient poll below and the escape hatch.)
let signedIn = await Task.detached {
FileManager.default.ubiquityIdentityToken != nil
}.value
guard attempt == connectAttempt else { return }
guard signedIn else {
log.error("connect[\(attempt)]: not signed into iCloud → unavailable")
iCloudStatus = .unavailable
return
}
// Signed in, but the container may still be provisioning — common right
// after enabling iCloud Drive. Keep polling patiently: we'd rather hold the
// spinner than misreport a working account as unavailable. We only give up
// after a considerable timeout; the user can bail sooner via the connecting
// screen's escape hatch (which bumps connectAttempt and stops this loop).
var resolved: URL?
let deadline = Date().addingTimeInterval(Self.connectTimeoutSeconds)
while resolved == nil {
let url = await Task.detached {
FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier)
}.value
guard attempt == connectAttempt else { return }
if let url {
resolved = url
break
}
if Date() >= deadline {
log.error("connect[\(attempt)]: container still nil after \(Int(Self.connectTimeoutSeconds))s → unavailable")
iCloudStatus = .unavailable
return
}
log.info("connect[\(attempt)]: container nil — still provisioning, retrying")
try? await Task.sleep(for: .seconds(2))
guard attempt == connectAttempt else { return }
}
guard let containerURL = resolved else { return }
log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)")
let store = DocumentFileStore(root: containerURL.appendingPathComponent("Documents", isDirectory: true))
// Safety net only: prepareDirectories is a local op that effectively never
// blocks, but if the first container file op ever wedges we don't want an
// eternal spinner. This is generous — it isn't the connect path's clock.
let safety = Task { [weak self] in
try? await Task.sleep(for: .seconds(30))
guard let self, !Task.isCancelled else { return }
if self.iCloudStatus == .checking, attempt == self.connectAttempt {
self.log.error("connect[\(attempt)]: prepareDirectories wedged 30s → unavailable")
self.iCloudStatus = .unavailable
}
}
log.info("connect[\(attempt)]: preparing directories…")
await store.prepareDirectories(["Splits", "Workouts", "Stubs"])
safety.cancel()
guard attempt == connectAttempt else { return }
self.store = store
self.tombstones = TombstoneStore(store: store)
iCloudStatus = .available
log.info("connect[\(attempt)]: directories ready → available")
WorkoutsModelContainer.persistCurrentIdentityToken()
await reconcile()
startMonitoring(documentsURL: store.rootURL)
cleanupOldStubs()
// Off the connect path so opening the gate isn't delayed by the settle wait.
Task { await self.seedOrReconcile() }
}
/// Invoked from the connecting screen when the user chooses not to keep
/// waiting. Bumps `connectAttempt` to stop the in-flight poll loop, then drops
/// to the end-of-the-line gate (with its Try Again).
func abandonWaiting() {
guard iCloudStatus == .checking else { return }
connectAttempt += 1
iCloudStatus = .unavailable
log.info("connect: abandoned by user → unavailable")
}
// MARK: - Monitoring
private func startMonitoring(documentsURL: URL) {
monitorTask?.cancel()
let monitor = MetadataObserver(documentsURL: documentsURL)
self.monitor = monitor
monitor.start()
monitorTask = Task { [weak self] in
for await batch in monitor.events() {
await self?.handle(batch)
}
}
}
private func handle(_ batch: [FileChangeEvent]) async {
for event in batch {
switch event {
case .added(let path), .modified(let path):
if path.hasPrefix("Stubs/") {
let id = idFromStubPath(path)
deleteCachedEntity(id: id)
// A stub can arrive before (or instead of) the deleting device's
// live-file removal; drop any live file for this id so the delete
// takes effect and reconcile can't re-import it.
await removeLiveFile(forID: id)
} else {
await importFile(relativePath: path)
}
case .removed(let path):
if !path.hasPrefix("Stubs/") {
deleteCachedEntity(jsonRelativePath: path)
}
}
}
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
/// 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 —
// and re-push authoritative state (via onCacheChanged → pushAll) so the watch
// replaces its stale set and drops the deleted workout.
if let tombstones, await tombstones.stubExists(id: doc.id) {
onCacheChanged?()
return
}
await save(workout: doc)
}
// MARK: - Public CRUD (write path: files only)
/// Returns the *effective* id of the split that was written — normally `doc.id`,
/// but the clone's fresh id when an edited seed forks. Open views follow the swap
/// via `currentSplitID(for:)`.
@discardableResult
func save(split doc: SplitDocument) async -> String {
guard let store else { return doc.id }
// Seeds are immutable: a real edit forks the seed into a user-owned split and
// soft-deletes the original, keeping the curated seed intact and restorable. A
// pristine (no-op) save must NOT fork — the edit sheets stamp `updatedAt`
// unconditionally, so merely opening and saving a seed's editor without a change
// would otherwise clone it.
if SeedLibrary.isSeed(id: doc.id), !SeedLibrary.isPristine(doc) {
return await cloneSeedOnEdit(doc)
}
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)
}
return doc.id
}
/// Fork an edited seed into a user-owned split. Writes the clone under a fresh
/// ULID and upserts it into the cache immediately (same rationale as
/// `ingestFromWatch`: a same-process write doesn't reliably wake the metadata
/// observer, and open screens must see the clone the moment `save` returns), then
/// soft-deletes the seed and drops its cache entity. Records the id redirect so a
/// view still holding the seed's id follows the identity swap.
private func cloneSeedOnEdit(_ doc: SplitDocument) async -> String {
guard let store else { return doc.id }
var clone = doc
clone.id = ULID.make()
clone.createdAt = Date()
// Exercise ids are kept as-is — they only need uniqueness within the document.
do {
try await store.write(clone, to: clone.relativePath)
CacheMapper.upsertSplit(clone, relativePath: clone.relativePath, into: context)
// Soft-delete the seed (writes its veto stub, then removes the live file),
// then evict its now-orphaned cache entity.
await softDelete(id: doc.id, kind: "split", livePath: doc.relativePath)
deleteCachedEntity(id: doc.id)
try context.save()
cloneRedirects[doc.id] = clone.id
lastSyncError = nil
await repointWorkouts(from: doc.id, to: clone.id)
onCacheChanged?()
return clone.id
} catch {
report("Failed to fork edited starter split", error)
return doc.id
}
}
/// 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) }
}
/// Push edited machine settings onto the originating split's exercise (matched
/// by name — logs reference exercises by name only), following the seed
/// clone-on-edit redirect so the live clone is updated. Skips silently when the
/// split is gone or no exercise matches, and when nothing changed (so a pristine
/// seed isn't needlessly forked). Never caches the split's id across the save,
/// since saving a seed mints a new one.
func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async {
guard let splitID else { return }
let liveID = currentSplitID(for: splitID)
guard let split = CacheMapper.fetchSplit(id: liveID, in: context) else { return }
var splitDoc = SplitDocument(from: split)
guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
guard splitDoc.exercises[idx].machineSettings != settings else { return }
splitDoc.exercises[idx].machineSettings = settings
splitDoc.updatedAt = Date()
await save(split: splitDoc)
}
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)
}
// Drive the HealthKit writer: a watch-recorded doc cancels any pending estimate;
// a completed doc (re)considers an estimate (the writer dedupes on metrics).
if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) }
if doc.status == WorkoutStatus.completed.rawValue { onWorkoutCompleted?(doc) }
}
func delete(split: Split) async {
let id = split.id, livePath = split.jsonRelativePath
await softDelete(id: id, kind: "split", livePath: livePath)
deleteCachedEntity(id: id)
saveCacheAndNotify()
}
func delete(workout: Workout) async {
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
/// the delete via the stub even if they were offline for the file removal.
private func softDelete(id: String, kind: String, livePath: String) async {
guard let store, let tombstones else { return }
do {
try await tombstones.writeTombstone(Tombstone(id: id, deletedAt: Date(), kind: kind))
try await store.remove(at: livePath)
lastSyncError = nil
} catch {
report("Failed to delete \(id)", error)
}
}
// MARK: - Import / reconcile
private func importFile(relativePath: String) async {
guard let store, let tombstones else { return }
let data: Data
do {
data = try await store.readData(from: relativePath)
} catch {
// Includes eviction-download timeouts — log loudly, never silently
// treat an unreadable file as absent. The next monitor event or
// reconcile retries it.
log.error("import: read failed for \(relativePath, privacy: .public): \(error)")
report("Failed to read \(relativePath)", error)
return
}
if relativePath.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
}
}
/// Full sync against the current file set — imports new/changed files and
/// prunes entities whose file is gone or tombstoned. Runs on connect so
/// changes accumulated while the app was closed are picked up.
private func reconcile() async {
guard let store, let tombstones else { return }
isSyncing = true
defer { isSyncing = false }
// IDs from stub filenames, not stub contents — an evicted stub must
// still count as a tombstone or the deleted record resurrects.
let tombstoned = await tombstones.listStubIDs()
let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") }
var liveSplitIDs = Set<String>()
var liveWorkoutIDs = Set<String>()
var unreadablePaths = Set<String>()
for path in dataFiles {
let data: Data
do {
data = try await store.readData(from: path)
} catch {
// A failed read (eviction-download timeout, coordination error)
// is not proof the record is gone — remember the path so the
// prune below keeps its cache entity. Skipping silently here is
// how evicted records used to vanish on storage-constrained
// devices.
log.error("reconcile: read failed for \(path, privacy: .public): \(error)")
report("Failed to read \(path)", error)
unreadablePaths.insert(path)
continue
}
if path.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
liveSplitIDs.insert(doc.id)
} else if path.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
liveWorkoutIDs.insert(doc.id)
}
}
// Prune cache entities no longer backed by a live file — but never for
// a path that failed to read this pass (it may just be un-downloadable
// right now; pruning would make an eviction look like a deletion).
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
for s in splits where !liveSplitIDs.contains(s.id) && !unreadablePaths.contains(s.jsonRelativePath) {
context.delete(s)
}
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
for w in workouts where !liveWorkoutIDs.contains(w.id) && !unreadablePaths.contains(w.jsonRelativePath) {
context.delete(w)
}
}
do {
try context.save()
// A fully clean pass supersedes any earlier failure; a pass with
// unreadable files keeps its own report visible.
if unreadablePaths.isEmpty { lastSyncError = nil }
} catch {
report("Cache save failed", error)
}
onCacheChanged?()
}
// MARK: - Cache deletes
private func deleteCachedEntity(id: String) {
if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
}
private func deleteCachedEntity(jsonRelativePath path: String) {
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
splits.forEach(context.delete)
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
workouts.forEach(context.delete)
}
}
private func idFromStubPath(_ path: String) -> String {
(path as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "")
}
/// Remove any live data file for `id` when its tombstone arrives. Matches by
/// filename identity across every non-`Stubs/` path — deliberately NOT
/// `resolveExistingPath(forID:)`, which would also match the stub itself.
private func removeLiveFile(forID id: String) async {
guard let store else { return }
let target = "\(id).json"
let livePaths = await store.list().filter {
!$0.hasPrefix("Stubs/") && ($0 as NSString).lastPathComponent == target
}
for path in livePaths {
try? await store.remove(at: path)
}
}
// MARK: - Maintenance
private func cleanupOldStubs() {
guard let tombstones else { return }
let exempt = SeedLibrary.seedIDs
Task.detached(priority: .utility) {
// Downloads evicted stubs to read their deletedAt, prunes past the
// 30-day grace period. Seed tombstones veto resurrection permanently, so
// they're exempt and never pruned.
try? await tombstones.prune(exempting: exempt)
}
}
// MARK: - Seeding
/// True when the container holds no documents at all — not one data file and not
/// one tombstone. A user with only workouts, or any tombstone (i.e. a prior
/// delete), is not a new user, so we never auto-seed over them. Placeholder-aware
/// by construction (`store.list()` sees evicted files too).
private func containerEmpty() async -> Bool {
guard let store else { return false }
return await store.list().isEmpty
}
/// Write the starter library exactly once, only into a verifiably empty container
/// (the true first run). Deferred and re-checked after a settle delay: an empty
/// listing right after connect can just mean the metadata index hasn't populated
/// yet, and seeding into that illusion would duplicate a library that's about to
/// arrive.
private func autoSeedIfEmpty() async {
guard let store, await containerEmpty() else { return }
// Give the metadata index a chance to surface existing files before we conclude
// the account is genuinely new.
try? await Task.sleep(for: .seconds(5))
guard iCloudStatus == .available, await containerEmpty() else { return }
for seed in SeedLibrary.seeds {
do {
// Verbatim bundle bytes: byte-identical files across devices make a
// same-path conflict (two devices seeding at once) semantically empty.
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
} catch {
report("Failed to seed \(seed.doc.name)", error)
}
}
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
/// 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()
}
}
/// 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 }
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?()
}
}
/// 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
/// Record a non-fatal sync failure: log it and publish it as `lastSyncError`
/// for the UI to render. Replaces silent `try?` / print-only handling.
private func report(_ message: String, _ error: Error? = nil) {
let full = error.map { "\(message): \($0.localizedDescription)" } ?? message
print("[Sync] \(full)")
lastSyncError = full
}
}