Drag-to-reorder writes Routine.order through the document, which used to read as a content edit: isPristine would fork a starter for a mere reorder, and reconcile's semantic compare would clobber a reordered seed file back to bundle order. Both now normalize order (and updatedAt) away — a fixed- ULID file still never holds user content; ordering is bookkeeping. SyncEngine gains restoreSeed(id:) — the per-seed analog of the bulk restore, sharing one restoreSeedIfEligible core — and duplicate(routine:), which copies any routine (starter or not) to a fresh ULID with fresh exercise ids, a unique "… Copy" name, and last position in the list. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
1324 lines
64 KiB
Swift
1324 lines
64 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 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<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)?
|
|
|
|
/// 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<Void, Never>?
|
|
private var connectAttempt = 0
|
|
private var backlog: WriteBacklog
|
|
private let backlogURL: URL
|
|
private var drainTask: Task<Void, Never>?
|
|
|
|
/// 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)
|
|
await repointSchedules(from: doc.id, to: clone.id, newName: clone.name)
|
|
onCacheChanged?()
|
|
return clone.id
|
|
} catch {
|
|
report("Failed to fork edited starter routine", error)
|
|
return doc.id
|
|
}
|
|
}
|
|
|
|
/// Duplicate any routine — user-authored or a starter — into a fresh, fully
|
|
/// independent user routine. Unlike clone-on-edit (which forks a *seed* under the
|
|
/// covers and preserves its identity), a duplicate of a starter is deliberately a
|
|
/// plain user routine: the copy gets a brand-new random ULID, so it is NOT a seed —
|
|
/// no starter badge, and editing it never triggers clone-on-edit. Every exercise also
|
|
/// gets a fresh ULID (mandatory: `Exercise.id` is `@Attribute(.unique)` and both
|
|
/// routines stay live, so shared ids would violate the constraint), with order and
|
|
/// content otherwise preserved. The copy is named with the next free "… Copy" variant
|
|
/// and appended to the end of the routine list. Returns the new routine's id, or nil
|
|
/// when the engine isn't connected.
|
|
@discardableResult
|
|
func duplicate(routine: Routine) async -> String? {
|
|
guard store != nil else { return nil }
|
|
|
|
var doc = RoutineDocument(from: routine)
|
|
doc.id = ULID.make()
|
|
let now = Date()
|
|
doc.createdAt = now
|
|
doc.updatedAt = now
|
|
// Fresh id per exercise — both the source and the copy stay live, and
|
|
// `Exercise.id` is unique-constrained, so the copy can't reuse the originals.
|
|
for i in doc.exercises.indices {
|
|
doc.exercises[i].id = ULID.make()
|
|
}
|
|
|
|
// Append after the current last routine, and pick a name unique among live ones.
|
|
let liveRoutines = (try? context.fetch(FetchDescriptor<Routine>())) ?? []
|
|
doc.order = (liveRoutines.map(\.order).max() ?? -1) + 1
|
|
doc.name = RoutineNaming.uniqueName(base: routine.name, existing: Set(liveRoutines.map(\.name)))
|
|
|
|
// A fresh ULID means this is not a seed, so `save` takes the plain path (no fork).
|
|
return await save(routine: doc)
|
|
}
|
|
|
|
/// 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<Workout>(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) }
|
|
}
|
|
|
|
/// Rewrite `routineID` on every schedule that references `oldID`, so the plan layer
|
|
/// (the Today board, edit form, adherence tracks) keeps resolving after a seed fork —
|
|
/// durably, across relaunches, which the in-memory `cloneRedirects` map can't cover.
|
|
/// Unlike a workout's frozen "what it was run as" `routineName`, a schedule's
|
|
/// `routineName` is a display *fallback* for a live pointer, so it tracks the clone's
|
|
/// current name. Best effort per schedule: a failed rewrite is reported and the
|
|
/// redirect map still covers it for this session.
|
|
private func repointSchedules(from oldID: String, to newID: String, newName: String) async {
|
|
guard let store else { return }
|
|
let referencing = (try? context.fetch(
|
|
FetchDescriptor<Schedule>(predicate: #Predicate { $0.routineID == oldID })
|
|
)) ?? []
|
|
guard !referencing.isEmpty else { return }
|
|
for schedule in referencing {
|
|
var sDoc = ScheduleDocument(from: schedule)
|
|
sDoc.routineID = newID
|
|
sDoc.routineName = newName
|
|
sDoc.updatedAt = Date()
|
|
do {
|
|
try await store.write(sDoc, to: sDoc.relativePath)
|
|
CacheMapper.upsertSchedule(sDoc, relativePath: sDoc.relativePath, into: context)
|
|
} catch {
|
|
report("Failed to repoint schedule \(sDoc.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<String>()
|
|
var liveWorkoutIDs = Set<String>()
|
|
var liveScheduleIDs = 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
|
|
}
|
|
// 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<Routine>()) {
|
|
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<Workout>()) {
|
|
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<Schedule>()) {
|
|
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<Routine>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
|
routines.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
|
|
}
|
|
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
|
workouts.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
|
|
}
|
|
if let schedules = try? context.fetch(FetchDescriptor<Schedule>(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()
|
|
}
|
|
// After the seed set has settled, heal schedules whose routine reference died
|
|
// before fork-time repointing existed (cheap, idempotent — every launch).
|
|
await repairScheduleReferences()
|
|
}
|
|
|
|
/// Repair schedules whose `routineID` matches no live routine, re-attaching each to
|
|
/// the unique live routine carrying the schedule's `routineName` (the pure
|
|
/// `ScheduleRepairPlanner` decides; ambiguous or unmatched ones are left alone).
|
|
/// Batched: one cache save + one change notification when anything was repaired —
|
|
/// deliberately NOT routed through `save(schedule:)`, which would fire the watch
|
|
/// push and reminder resync once per schedule.
|
|
private func repairScheduleReferences() async {
|
|
guard let store else { return }
|
|
let schedules = (try? context.fetch(FetchDescriptor<Schedule>())) ?? []
|
|
let routines = (try? context.fetch(FetchDescriptor<Routine>())) ?? []
|
|
|
|
let repairs = ScheduleRepairPlanner.plans(
|
|
schedules: schedules.map {
|
|
ScheduleRepairRef(id: $0.id, routineID: $0.routineID, routineName: $0.routineName)
|
|
},
|
|
liveRoutines: routines.map { RoutineRepairRef(id: $0.id, name: $0.name) }
|
|
)
|
|
guard !repairs.isEmpty else { return }
|
|
|
|
var repaired = 0
|
|
for repair in repairs {
|
|
guard let schedule = CacheMapper.fetchSchedule(id: repair.scheduleID, in: context) else { continue }
|
|
var doc = ScheduleDocument(from: schedule)
|
|
doc.routineID = repair.newRoutineID
|
|
doc.updatedAt = Date()
|
|
do {
|
|
try await store.write(doc, to: doc.relativePath)
|
|
CacheMapper.upsertSchedule(doc, relativePath: doc.relativePath, into: context)
|
|
repaired += 1
|
|
} catch {
|
|
report("Failed to repair schedule \(doc.id) routine reference", error)
|
|
}
|
|
}
|
|
|
|
if repaired > 0 {
|
|
log.info("schedule repair: re-attached \(repaired) schedule(s) to live routines")
|
|
do { try context.save() } catch { report("Cache save failed", error) }
|
|
onCacheChanged?()
|
|
}
|
|
}
|
|
|
|
/// 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<Routine>())) ?? []).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<Routine>())) ?? []).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, tombstones != nil else { return 0 }
|
|
|
|
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
|
|
|
|
var restored = 0
|
|
for seed in SeedLibrary.seeds {
|
|
if await restoreSeedIfEligible(seed, livePaths: livePaths) { restored += 1 }
|
|
}
|
|
|
|
if restored > 0 {
|
|
do { try context.save() } catch { report("Cache save failed", error) }
|
|
onCacheChanged?()
|
|
}
|
|
return restored
|
|
}
|
|
|
|
/// The per-seed analog of `restoreSeeds()` — restore ONE starter routine by its fixed
|
|
/// seed id (used by the Library tab's per-row restore). Runs the identical gate and
|
|
/// writes as the bulk path via the shared `restoreSeedIfEligible` core: a no-op unless
|
|
/// that seed is both absent and free of a live same-name routine, in which case it
|
|
/// lifts the veto stub and writes the current bundle bytes. Returns whether it wrote —
|
|
/// false for an unknown id, a not-connected engine, or a seed deliberately left alone.
|
|
func restoreSeed(id: String) async -> Bool {
|
|
guard let store, tombstones != nil else { return false }
|
|
guard let seed = SeedLibrary.seed(id: id) else { return false }
|
|
|
|
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
|
|
let wrote = await restoreSeedIfEligible(seed, livePaths: livePaths)
|
|
|
|
if wrote {
|
|
do { try context.save() } catch { report("Cache save failed", error) }
|
|
onCacheChanged?()
|
|
}
|
|
return wrote
|
|
}
|
|
|
|
/// Shared per-seed restore core for `restoreSeeds()` (bulk) and `restoreSeed(id:)`
|
|
/// (single). Gates on `SeedReconcilePlanner.shouldRestore` — restore only a seed that
|
|
/// is absent and whose name is free — then lifts its veto stub (if any) and writes the
|
|
/// verbatim bundle bytes, mirroring the cache entity. Live routine names are re-read
|
|
/// here (not passed in) so a prior write in the bulk loop is seen. Returns whether it
|
|
/// wrote; the caller owns the single `context.save()` + `onCacheChanged` fan-out.
|
|
private func restoreSeedIfEligible(_ seed: SeedLibrary.Seed, livePaths: Set<String>) async -> Bool {
|
|
guard let store, let tombstones else { return false }
|
|
|
|
let liveNames = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
|
|
guard SeedReconcilePlanner.shouldRestore(
|
|
hasLiveFile: livePaths.contains(seed.doc.relativePath),
|
|
seedName: seed.doc.name,
|
|
liveRoutineNames: liveNames
|
|
) else { return false }
|
|
|
|
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)
|
|
lastSyncError = nil
|
|
return true
|
|
} catch {
|
|
report("Failed to restore starter routine \(seed.doc.name)", error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// 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<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
|
|
}
|
|
|
|
// 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<Workout>(predicate: #Predicate { $0.routineID == routineID })
|
|
)) ?? []
|
|
let referencingSchedules = (try? context.fetch(
|
|
FetchDescriptor<Schedule>(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
|
|
}
|
|
}
|