Files
workouts/Workouts/Sync/SyncEngine.swift
T
rzen a4ed4df756 Make Apple Health workout recording watch-only
The phone no longer writes estimated Health workouts: the watch, which
runs the live session, is the sole recorder. Replaces WorkoutHealthWriter
with a WorkoutHealthDeleter that only removes a legacy phone-estimate
workout when its record is deleted here, drops the MET calorie table and
the phone's write/read Health scopes, and keeps phoneEstimate decodable
for existing documents.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
2026-07-08 07:56:19 -04:00

843 lines
39 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)?
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 }
// The month bucket in a workout's path derives from `start`, so editing the
// start date (or a device in a different time zone) can move the file to a new
// path. Capture the previously-written path before the upsert overwrites it, so
// we can remove the stale file below — otherwise the same id would live at two
// paths and the old copy would re-import on the next reconcile.
let previousPath = CacheMapper.fetchWorkout(id: doc.id, in: context)?.jsonRelativePath
do {
try await store.write(doc, to: doc.relativePath)
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
// Same id, new path: drop the orphaned file at the old bucket. The id lives
// on at the new path, so this is a plain removal — no tombstone (a tombstone
// would veto the record that just moved). Phone stays the sole writer.
if let previousPath, previousPath != doc.relativePath {
try? await store.remove(at: previousPath)
}
saveCacheAndNotify()
lastSyncError = nil
} catch {
report("Failed to save workout", error)
}
}
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:
// Re-check the veto against fresh state before overwriting — symmetric
// with `.write`. The batch above was gathered before a settle-window
// race: if the user forked/soft-deleted this seed (clone-on-edit writes
// a stub and removes the live file) after we listed the live paths,
// rewriting the seed bytes here would transiently resurrect the deleted
// seed alongside the user's clone. The stub now present vetoes that.
// (No name-collision guard here: on a legitimate upgrade the seed itself
// is a live split by that name, so a name check would block every upgrade.)
if await tombstones.stubExists(id: seed.id) { continue }
// Overwriting an existing seed-path file with canonical bundle bytes is
// otherwise 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
log.error("\(full, privacy: .public)")
lastSyncError = full
}
}