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 mirrors 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) and queues the file write in a per-document /// slot backlog (`WriteBacklog`), drained serially with backoff. A failed write /// therefore never silently evaporates an edit: it stays in its slot, the cache /// keeps showing it, and `writeQueueState` surfaces the stall to the UI. /// The observer and the connect-time reconcile re-apply idempotently and remain /// the sole channel for *remote* changes; both skip documents superseded by a /// queued-but-unwritten local change. @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 /// Resolved `Documents/` directory inside the ubiquity container — the root of /// the on-disk JSON document tree (`Splits/`, `Workouts/`, `Stubs/`) and the /// backup layer's `backupRoot`. Set once the container resolves in `connect()`; /// nil until then. Read-only for callers. private(set) var containerDocumentsURL: URL? /// True while a backup restore is replacing the document tree. The restore /// suspends the metadata observer itself (`beginRestore`/`endRestore`); this /// flag additionally vetoes a concurrent `connect()`/reconcile (e.g. a /// scenePhase foreground-return) so the two can't fight over the cache while a /// bulk file mirror is in flight. private(set) var isRestoring = 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? /// Rollup of the write backlog for the UI banner tiers (see `WriteBacklog`). private(set) var writeQueueState: WriteQueueState = .idle /// Number of queued-but-unwritten document writes. var pendingWriteCount: Int { backlog.count } /// 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 `routineID` 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 routine id. A seed /// redirects at most once, but the loop tolerates (and breaks) any chain or cycle. func currentRoutineID(for id: String) -> String { var current = id var seen: Set = [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)? /// Fired when a phone-side save first moves a workout from `.notStarted` into /// `.inProgress` — the moment a run actually begins. AppServices launches the /// watch session here rather than at creation, so peeking into a routine never /// spins one up. Watch-originated updates (`ingestFromWatch`) never fire it — /// the watch already owns a session — and neither does editing an old finished /// workout back to in-progress (the transition must be from `.notStarted`). var onWorkoutBecameActive: ((WorkoutDocument) -> 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? private var connectAttempt = 0 private var backlog: WriteBacklog private let backlogURL: URL private var drainTask: Task? /// 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, backlogURL: URL = WorkoutsModelContainer.pendingWritesURL) { self.modelContainer = container self.backlogURL = backlogURL // Writes queued in a previous run survive relaunch; anything stuck past // the TTL is dropped rather than drained stale into a container that has // moved on. (An account change wipes the sidecar file before this load — // see `WorkoutsModelContainer.wipeIfAccountChanged`.) var loaded = WriteBacklogFile.load(from: backlogURL) loaded.pruneExpired(now: Date()) self.backlog = loaded refreshQueueState() } // MARK: - Connection (deferred, patient) func connect() async { // Never reconnect/reconcile mid-restore: the backup layer is rewriting the // whole document tree and rebuilding the cache itself. Balanced by the // restore clearing `isRestoring` in `endRestore()`. guard !isRestoring else { log.info("connect: skipped — restore in progress"); return } 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…") // PINNED: the iCloud directory is "Splits" (routine documents live under it). // The directory name is on-disk data — the Split→Routine rename must not touch it. await store.prepareDirectories(["Splits", "Workouts", "Schedules", "Stubs"]) safety.cancel() guard attempt == connectAttempt else { return } self.store = store self.containerDocumentsURL = store.rootURL self.tombstones = TombstoneStore(store: store) iCloudStatus = .available log.info("connect[\(attempt)]: directories ready → available") WorkoutsModelContainer.persistCurrentIdentityToken() // Deliver any backlog surviving from a previous run before reconcile // lists the tree — reconcile skips pending ids anyway, but the sooner // queued edits hit disk the smaller that window is. kickDrain() 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: - Backup restore lifecycle /// Rebuild the SwiftData cache from whatever documents are currently on disk. /// The cache is a pure read-through projection of the files, so a full /// `reconcile()` — import every live file, prune every entity with no backing /// file — *is* a rebuild. Called by the backup layer after a restore has /// replaced the document tree; safe whenever the engine is connected (a nil /// store, i.e. not yet connected, no-ops and the next `connect()` rebuilds). func rebuildCache() async { await reconcile() } /// Suspend live file-watching for the duration of a restore. A restore mirrors /// many files at once; without this the observer would replay each as a live /// edit. Sets `isRestoring` (so a concurrent `connect()` bails) and tears down /// the metadata observer. Always balanced by `endRestore()`. func beginRestore() { isRestoring = true monitorTask?.cancel() monitorTask = nil monitor?.stop() monitor = nil // Queued writes reference pre-restore state; draining them into the // restored tree would corrupt it with edits the user chose to roll back. drainTask?.cancel() drainTask = nil backlog.removeAll() persistBacklog() refreshQueueState() log.info("restore: began — metadata observer suspended, write backlog cleared") } /// Resume live file-watching after a restore. Starts a FRESH metadata observer /// (which re-baselines its known paths, so the just-restored files don't replay /// as a flood of add/remove events) and clears `isRestoring`. Balanced with /// `beginRestore()`; the backup layer calls this on both success and failure. func endRestore() { isRestoring = false if let store { startMonitoring(documentsURL: store.rootURL) } log.info("restore: ended — metadata observer resumed") } // 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:)` mirrors it into /// the cache and queues the file write, 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 } // A queued-but-unwritten delete is the same veto — its stub just hasn't // landed on disk yet. if case .delete = backlog.pendingWrite(for: doc.id)?.payload { onCacheChanged?() return } // Per-log merge (H1's durable fix). The watch mixes `sendMessage` with a queued // `transferUserInfo` fallback, which are unordered, and either device can edit the // same workout at once. Rather than arbitrate by whole-document `updatedAt` — which // loses one side's edit wholesale — reconcile the incoming doc against the cache // log-by-log (`WorkoutMergePlanner`): newer per-log stamp wins, deletion tombstones // resolve absent logs, so edits to different exercises commute regardless of delivery // order. When the merge changes nothing (a stale or duplicate push), re-push // authoritative state so the watch corrects; otherwise persist the merged document // (which echoes back to the watch via `onCacheChanged`). if let cached = CacheMapper.fetchWorkout(id: doc.id, in: context) { let cachedDoc = WorkoutDocument(from: cached) let merged = WorkoutMergePlanner.merge(incoming: doc, cached: cachedDoc) guard merged != cachedDoc else { onCacheChanged?() return } await save(workout: merged, notifyingActive: false) } else { // First time we've seen this workout — nothing to merge against. await save(workout: doc, notifyingActive: false) } } // MARK: - Public CRUD (mirror-first: cache now, file via the write queue) /// Returns the *effective* id of the routine that was written — normally `doc.id`, /// but the clone's fresh id when an edited seed forks. Open views follow the swap /// via `currentRoutineID(for:)`. @discardableResult func save(routine doc: RoutineDocument) async -> String { // Seeds are immutable: a real edit forks the seed into a user-owned routine 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) } CacheMapper.upsertRoutine(doc, relativePath: doc.relativePath, into: context) saveCacheAndNotify() enqueueWrite(.routine(doc), timestamp: doc.updatedAt) return doc.id } /// Fork an edited seed into a user-owned routine. 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: RoutineDocument) 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.upsertRoutine(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. // PINNED: tombstone kind stays "split" — existing stubs on disk carry it. 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 routine", error) return doc.id } } /// Rewrite `routineID` on every workout that references `oldID`, so routine 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. `routineName` stays frozen /// at what the workout was started as, matching rename semantics for regular /// routines. 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(predicate: #Predicate { $0.routineID == oldID }) )) ?? [] guard !referencing.isEmpty else { return } for workout in referencing { var wDoc = WorkoutDocument(from: workout) wDoc.routineID = 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 routine", error) } } do { try context.save() } catch { report("Cache save failed", error) } } /// Push edited machine settings onto the originating routine'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 /// routine is gone or no exercise matches, and when nothing changed (so a pristine /// seed isn't needlessly forked). Never caches the routine's id across the save, /// since saving a seed mints a new one. func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, routineID: String?) async { guard let routineID else { return } let liveID = currentRoutineID(for: routineID) guard let routine = CacheMapper.fetchRoutine(id: liveID, in: context) else { return } var routineDoc = RoutineDocument(from: routine) guard let idx = routineDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return } guard routineDoc.exercises[idx].machineSettings != settings else { return } routineDoc.exercises[idx].machineSettings = settings routineDoc.updatedAt = Date() await save(routine: routineDoc) } func save(workout doc: WorkoutDocument) async { await save(workout: doc, notifyingActive: true) } /// `notifyingActive: false` is the watch-ingest path — the watch already runs /// its own session, so a watch-originated update must not fire the /// became-active hook (which would launch a second session at the wrist). private func save(workout doc: WorkoutDocument, notifyingActive: Bool) async { // 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 — // the queued write removes it after landing, otherwise the same id would live // at two paths and the old copy would re-import on the next reconcile. // (Both reads happen before the upsert mutates the same entity in place.) let previous = CacheMapper.fetchWorkout(id: doc.id, in: context) let previousPath = previous?.jsonRelativePath let previousStatus = previous?.status CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context) saveCacheAndNotify() enqueueWrite( .workout(doc), timestamp: doc.updatedAt, stalePath: previousPath != doc.relativePath ? previousPath : nil ) if notifyingActive, previousStatus == .notStarted, doc.status == WorkoutStatus.inProgress.rawValue { onWorkoutBecameActive?(doc) } } /// Persist a schedule. Schedules live in a flat `Schedules/` directory (no month /// bucketing), so — unlike `save(workout:)` — a save can never move the file to a /// new path: mirror the cache, then queue the plain write. func save(schedule doc: ScheduleDocument) async { CacheMapper.upsertSchedule(doc, relativePath: doc.relativePath, into: context) saveCacheAndNotify() enqueueWrite(.schedule(doc), timestamp: doc.updatedAt) } func delete(routine: Routine) async { let id = routine.id, livePath = routine.jsonRelativePath deleteCachedEntity(id: id) saveCacheAndNotify() // PINNED: tombstone kind stays "split" — existing stubs on disk carry it. enqueueWrite(.delete(id: id, kind: "split", livePath: livePath), timestamp: Date()) } func delete(workout: Workout) async { let id = workout.id, livePath = workout.jsonRelativePath deleteCachedEntity(id: id) saveCacheAndNotify() enqueueWrite(.delete(id: id, kind: "workout", livePath: livePath), timestamp: Date()) } func delete(schedule: Schedule) async { let id = schedule.id, livePath = schedule.jsonRelativePath deleteCachedEntity(id: id) saveCacheAndNotify() enqueueWrite(.delete(id: id, kind: "schedule", livePath: livePath), timestamp: Date()) } /// 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, immediately and outside /// the write queue. Used only by the multi-step flows that need their file ops /// ordered within a larger transaction (`cloneSeedOnEdit`, `performCleanup`); /// plain user deletes go through the queue as `.delete` payloads. 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: - Write queue /// Queue a file write whose cache mirror has already been applied, persist the /// backlog, and make sure the drain loop is running. The per-id slot keeps only /// the newest version (`WriteBacklog.enqueue` is newer-wins on `timestamp`). private func enqueueWrite(_ payload: PendingWrite.Payload, timestamp: Date, stalePath: String? = nil) { var write = PendingWrite(payload: payload, timestamp: timestamp, enqueuedAt: Date()) if let stalePath { write.stalePaths.insert(stalePath) } backlog.enqueue(write) persistBacklog() refreshQueueState() kickDrain() } private func kickDrain() { guard drainTask == nil, store != nil, !isRestoring else { return } drainTask = Task { [weak self] in await self?.drainBacklog() guard let self else { return } self.drainTask = nil // A write enqueued in the same beat the loop was exiting finds // `drainTask` still set and skips its kick — re-check. No spin: the // loop only exits non-empty when the kick guard blocks anyway. if !self.backlog.isEmpty { self.kickDrain() } } } /// Serial delivery of the backlog: one write at a time, oldest due slot first, /// exponential backoff per entry. Sleeps in short quanta while nothing is due /// so a fresh enqueue never waits out a long backoff. private func drainBacklog() async { while store != nil, !isRestoring, !backlog.isEmpty { guard let entry = backlog.nextDue(at: Date()) else { try? await Task.sleep(for: .seconds(1)) continue } await attempt(entry) } } /// Try every pending write immediately, ignoring backoff — the app is about to /// background (last chance before suspension), or a test needs determinism. func flushPendingWrites() async { guard store != nil, !isRestoring else { return } for entry in backlog.entries { await attempt(entry) } } private func attempt(_ entry: PendingWrite) async { switch await perform(entry) { case .success: backlog.resolve(id: entry.documentID, ifTimestampAtMost: entry.timestamp) case .retry: backlog.markFailed(id: entry.documentID, at: Date()) case .fault(let message): backlog.markFailed(id: entry.documentID, at: Date(), fault: message) } persistBacklog() refreshQueueState() } private enum WriteOutcome { case success case retry case fault(String) } private func perform(_ entry: PendingWrite) async -> WriteOutcome { guard let store, let tombstones else { return .retry } do { switch entry.payload { case .routine(let doc): try await store.write(doc, to: doc.relativePath) case .workout(let doc): try await store.write(doc, to: doc.relativePath) case .schedule(let doc): try await store.write(doc, to: doc.relativePath) case .delete(let id, let kind, let livePath): // The stub is the authoritative delete record (it vetoes // resurrection everywhere); the live-file removal is best-effort — // the observer's stub handling and reconcile both reap a straggler. try await tombstones.writeTombstone(Tombstone(id: id, deletedAt: entry.timestamp, kind: kind)) try? await store.remove(at: livePath) } for stale in entry.stalePaths where stale != entry.targetPath { try? await store.remove(at: stale) } return .success } catch { log.error("write queue: \(entry.documentID, privacy: .public) failed (attempt \(entry.attempts + 1)): \(error)") if let message = Self.unrecoverableDescription(for: error) { return .fault(message) } return .retry } } /// Errors no amount of retrying fixes — escalate the banner immediately. /// (The drain still retries them on the long cadence; conditions can change.) private static func unrecoverableDescription(for error: Error) -> String? { let ns = error as NSError guard ns.domain == NSCocoaErrorDomain else { return nil } switch ns.code { case NSFileWriteOutOfSpaceError: return "iPhone storage is full — changes can't be saved to iCloud." case NSFileWriteVolumeReadOnlyError: return "iCloud storage is read-only — changes can't be saved." default: return nil } } private func persistBacklog() { WriteBacklogFile.save(backlog, to: backlogURL) } private func refreshQueueState() { let state: WriteQueueState if backlog.isEmpty { state = .idle } else if let fault = backlog.firstFaultMessage { state = .fault(fault) } else if backlog.maxAttempts >= WriteBacklog.tier2AttemptThreshold { state = .fault("Changes aren't reaching iCloud.") } else if backlog.maxAttempts >= WriteBacklog.tier1AttemptThreshold { state = .retrying } else { state = .pending } if state != writeQueueState { writeQueueState = state } } /// True when a queued-but-unwritten local change for `id` is at least as new /// as the file content just read — the cache already reflects the newer /// pending version, so importing the older file would regress it until the /// drain rewrites. (A pending `.delete` compares via its deletion stamp and /// keeps the import from resurrecting the entity.) private func supersededByPendingWrite(id: String, fileTimestamp: Date) -> Bool { guard let pending = backlog.pendingWrite(for: id) else { return false } return pending.timestamp >= fileTimestamp } // 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 } // PINNED: routine documents live under the "Splits/" directory on disk — the // path prefix is data, unchanged by the Split→Routine symbol rename. if relativePath.hasPrefix("Splits/") { guard let doc = try? DocumentCoder.decode(RoutineDocument.self, from: data), doc.isReadable else { return } if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return } if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return } CacheMapper.upsertRoutine(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 } if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return } CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context) } else if relativePath.hasPrefix("Schedules/") { guard let doc = try? DocumentCoder.decode(ScheduleDocument.self, from: data), doc.isReadable else { return } if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return } if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return } CacheMapper.upsertSchedule(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 liveRoutineIDs = Set() var liveWorkoutIDs = Set() var liveScheduleIDs = Set() var unreadablePaths = Set() 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 } // PINNED: routine documents live under "Splits/" on disk (see importFile). if path.hasPrefix("Splits/") { guard let doc = try? DocumentCoder.decode(RoutineDocument.self, from: data), doc.isReadable else { continue } if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue } liveRoutineIDs.insert(doc.id) if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue } CacheMapper.upsertRoutine(doc, relativePath: path, into: context) } 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 } liveWorkoutIDs.insert(doc.id) if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue } CacheMapper.upsertWorkout(doc, relativePath: path, into: context) } else if path.hasPrefix("Schedules/") { guard let doc = try? DocumentCoder.decode(ScheduleDocument.self, from: data), doc.isReadable else { continue } if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue } liveScheduleIDs.insert(doc.id) if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue } CacheMapper.upsertSchedule(doc, relativePath: path, into: context) } } // 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), and // never for an id with a queued-but-unwritten write (its file simply // hasn't landed yet; pruning would evaporate the pending edit's mirror). if let routines = try? context.fetch(FetchDescriptor()) { for s in routines where !liveRoutineIDs.contains(s.id) && !unreadablePaths.contains(s.jsonRelativePath) && backlog.pendingWrite(for: s.id) == nil { context.delete(s) } } if let workouts = try? context.fetch(FetchDescriptor()) { for w in workouts where !liveWorkoutIDs.contains(w.id) && !unreadablePaths.contains(w.jsonRelativePath) && backlog.pendingWrite(for: w.id) == nil { context.delete(w) } } if let schedules = try? context.fetch(FetchDescriptor()) { for s in schedules where !liveScheduleIDs.contains(s.id) && !unreadablePaths.contains(s.jsonRelativePath) && backlog.pendingWrite(for: s.id) == nil { context.delete(s) } } 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.fetchRoutine(id: id, in: context) { context.delete(s) } if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) } if let sc = CacheMapper.fetchSchedule(id: id, in: context) { context.delete(sc) } } /// Observer `.removed` handling. Skips entities with a queued-but-unwritten /// write: their file may simply not have landed yet (or an old-path removal /// raced the rewrite), and the pending edit's mirror must survive until the /// drain delivers it. private func deleteCachedEntity(jsonRelativePath path: String) { if let routines = try? context.fetch(FetchDescriptor(predicate: #Predicate { $0.jsonRelativePath == path })) { routines.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete) } if let workouts = try? context.fetch(FetchDescriptor(predicate: #Predicate { $0.jsonRelativePath == path })) { workouts.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete) } if let schedules = try? context.fetch(FetchDescriptor(predicate: #Predicate { $0.jsonRelativePath == path })) { schedules.filter { backlog.pendingWrite(for: $0.id) == nil }.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.upsertRoutine(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 routine) /// • 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 liveRoutineNames = Set(((try? context.fetch(FetchDescriptor())) ?? []).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: RoutineDocument? if livePaths.contains(seed.doc.relativePath) { guard let data = try? await store.readData(from: seed.doc.relativePath), let decoded = try? DocumentCoder.decode(RoutineDocument.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, liveRoutineNames: liveRoutineNames) { 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 routine 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())) ?? []).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 /// Routines"). For each seed with no live file and no live same-name routine, 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())) ?? []).map(\.name)) guard SeedReconcilePlanner.shouldRestore( hasLiveFile: livePaths.contains(seed.doc.relativePath), seedName: seed.doc.name, liveRoutineNames: 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.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context) restored += 1 lastSyncError = nil } catch { report("Failed to restore starter routine \(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.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context) lastSyncError = nil return true } catch { report("Failed to reconcile starter routine \(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 routines/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 or schedule could reference /// any routine and silently proceeding could misjudge it as unreferenced. func scanForDuplicates() async throws -> DuplicateCleanupPlan { guard let store else { throw DuplicateScanError.notConnected } let paths = await store.list().filter { !$0.hasPrefix("Stubs/") } var routines: [RoutineDocument] = [] var workouts: [WorkoutDocument] = [] var referencedRoutineIDs = Set() 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 } // PINNED: routine documents live under "Splits/" on disk (see importFile). if path.hasPrefix("Splits/") { do { let doc = try DocumentCoder.decode(RoutineDocument.self, from: data) // Quarantined (written by a newer app version) routines are never // judged as duplicates or deleted. if doc.isReadable { routines.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 routineID still protects that routine from // deletion even though the workout itself is excluded below. if let routineID = doc.routineID { referencedRoutineIDs.insert(routineID) referencedRoutineIDs.insert(currentRoutineID(for: routineID)) } if doc.isReadable { workouts.append(doc) } } catch { log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)") failedPaths.append(path) } } else if path.hasPrefix("Schedules/") { do { let doc = try DocumentCoder.decode(ScheduleDocument.self, from: data) // Schedules are never duplicate candidates themselves, but the // routine a schedule (even a quarantined one) points at must // survive cleanup, or the schedule would dangle. referencedRoutineIDs.insert(doc.routineID) referencedRoutineIDs.insert(currentRoutineID(for: doc.routineID)) } catch { log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)") failedPaths.append(path) } } } guard failedPaths.isEmpty else { throw DuplicateScanError.unreadableFiles(failedPaths.sorted()) } referencedRoutineIDs.formUnion(cloneRedirects.values) return DuplicateCleanupPlanner.plan( routines: routines, workouts: workouts, referencedRoutineIDs: referencedRoutineIDs, 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 -> (routinesDeleted: Int, workoutsDeleted: Int, skipped: Int) { var routinesDeleted = 0 var workoutsDeleted = 0 var skipped = 0 for group in plan.routineGroups { for doc in group.delete { let routineID = doc.id if SeedLibrary.isSeed(id: routineID) { skipped += 1 continue } let referencingWorkouts = (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.routineID == routineID }) )) ?? [] let referencingSchedules = (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.routineID == routineID }) )) ?? [] guard referencingWorkouts.isEmpty, referencingSchedules.isEmpty else { skipped += 1 continue } // PINNED: tombstone kind stays "split" — existing stubs on disk carry it. if let cached = CacheMapper.fetchRoutine(id: routineID, in: context) { await softDelete(id: routineID, kind: "split", livePath: cached.jsonRelativePath) } else { await softDelete(id: routineID, kind: "split", livePath: doc.relativePath) } deleteCachedEntity(id: routineID) routinesDeleted += 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 (routinesDeleted, 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 } }