Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
This commit is contained in:
+180
-93
@@ -55,13 +55,13 @@ final class SyncEngine {
|
||||
/// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still
|
||||
/// holding the seed's id resolves to the live clone. In-memory only (@Observable
|
||||
/// notifies on change); not persisted — durability comes from `repointWorkouts`,
|
||||
/// which rewrites `splitID` on the workout documents themselves at fork time.
|
||||
/// 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 split id. A seed
|
||||
/// 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 currentSplitID(for id: String) -> String {
|
||||
func currentRoutineID(for id: String) -> String {
|
||||
var current = id
|
||||
var seen: Set<String> = [current]
|
||||
while let next = cloneRedirects[current], seen.insert(next).inserted {
|
||||
@@ -74,6 +74,14 @@ final class SyncEngine {
|
||||
/// 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?
|
||||
@@ -176,7 +184,9 @@ final class SyncEngine {
|
||||
}
|
||||
}
|
||||
log.info("connect[\(attempt)]: preparing directories…")
|
||||
await store.prepareDirectories(["Splits", "Workouts", "Stubs"])
|
||||
// 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 }
|
||||
|
||||
@@ -323,21 +333,21 @@ final class SyncEngine {
|
||||
onCacheChanged?()
|
||||
return
|
||||
}
|
||||
await save(workout: merged)
|
||||
await save(workout: merged, notifyingActive: false)
|
||||
} else {
|
||||
// First time we've seen this workout — nothing to merge against.
|
||||
await save(workout: doc)
|
||||
await save(workout: doc, notifyingActive: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public CRUD (mirror-first: cache now, file via the write queue)
|
||||
|
||||
/// Returns the *effective* id of the split that was written — normally `doc.id`,
|
||||
/// 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 `currentSplitID(for:)`.
|
||||
/// via `currentRoutineID(for:)`.
|
||||
@discardableResult
|
||||
func save(split doc: SplitDocument) async -> String {
|
||||
// Seeds are immutable: a real edit forks the seed into a user-owned split and
|
||||
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
|
||||
@@ -346,19 +356,19 @@ final class SyncEngine {
|
||||
return await cloneSeedOnEdit(doc)
|
||||
}
|
||||
|
||||
CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
|
||||
CacheMapper.upsertRoutine(doc, relativePath: doc.relativePath, into: context)
|
||||
saveCacheAndNotify()
|
||||
enqueueWrite(.split(doc), timestamp: doc.updatedAt)
|
||||
enqueueWrite(.routine(doc), timestamp: doc.updatedAt)
|
||||
return doc.id
|
||||
}
|
||||
|
||||
/// Fork an edited seed into a user-owned split. Writes the clone under a fresh
|
||||
/// 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: SplitDocument) async -> String {
|
||||
private func cloneSeedOnEdit(_ doc: RoutineDocument) async -> String {
|
||||
guard let store else { return doc.id }
|
||||
var clone = doc
|
||||
clone.id = ULID.make()
|
||||
@@ -366,9 +376,10 @@ final class SyncEngine {
|
||||
// Exercise ids are kept as-is — they only need uniqueness within the document.
|
||||
do {
|
||||
try await store.write(clone, to: clone.relativePath)
|
||||
CacheMapper.upsertSplit(clone, relativePath: clone.relativePath, into: context)
|
||||
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()
|
||||
@@ -378,63 +389,73 @@ final class SyncEngine {
|
||||
onCacheChanged?()
|
||||
return clone.id
|
||||
} catch {
|
||||
report("Failed to fork edited starter split", error)
|
||||
report("Failed to fork edited starter routine", error)
|
||||
return doc.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite `splitID` on every workout that references `oldID`, so split lookups
|
||||
/// 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. `splitName` stays frozen
|
||||
/// which the in-memory `cloneRedirects` map can't reach. `routineName` stays frozen
|
||||
/// at what the workout was started as, matching rename semantics for regular
|
||||
/// splits. Best effort per workout: a failed rewrite is reported and the redirect
|
||||
/// 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.splitID == oldID })
|
||||
FetchDescriptor<Workout>(predicate: #Predicate { $0.routineID == oldID })
|
||||
)) ?? []
|
||||
guard !referencing.isEmpty else { return }
|
||||
for workout in referencing {
|
||||
var wDoc = WorkoutDocument(from: workout)
|
||||
wDoc.splitID = newID
|
||||
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 split", error)
|
||||
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 split's exercise (matched
|
||||
/// 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
|
||||
/// split is gone or no exercise matches, and when nothing changed (so a pristine
|
||||
/// seed isn't needlessly forked). Never caches the split's id across the save,
|
||||
/// 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, splitID: String?) async {
|
||||
guard let splitID else { return }
|
||||
let liveID = currentSplitID(for: splitID)
|
||||
guard let split = CacheMapper.fetchSplit(id: liveID, in: context) else { return }
|
||||
var splitDoc = SplitDocument(from: split)
|
||||
guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
|
||||
guard splitDoc.exercises[idx].machineSettings != settings else { return }
|
||||
splitDoc.exercises[idx].machineSettings = settings
|
||||
splitDoc.updatedAt = Date()
|
||||
await save(split: splitDoc)
|
||||
func 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.
|
||||
let previousPath = CacheMapper.fetchWorkout(id: doc.id, in: context)?.jsonRelativePath
|
||||
// (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(
|
||||
@@ -442,12 +463,26 @@ final class SyncEngine {
|
||||
timestamp: doc.updatedAt,
|
||||
stalePath: previousPath != doc.relativePath ? previousPath : nil
|
||||
)
|
||||
if notifyingActive, previousStatus == .notStarted,
|
||||
doc.status == WorkoutStatus.inProgress.rawValue {
|
||||
onWorkoutBecameActive?(doc)
|
||||
}
|
||||
}
|
||||
|
||||
func delete(split: Split) async {
|
||||
let id = split.id, livePath = split.jsonRelativePath
|
||||
/// 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())
|
||||
}
|
||||
|
||||
@@ -458,6 +493,13 @@ final class SyncEngine {
|
||||
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() {
|
||||
@@ -552,10 +594,12 @@ final class SyncEngine {
|
||||
guard let store, let tombstones else { return .retry }
|
||||
do {
|
||||
switch entry.payload {
|
||||
case .split(let doc):
|
||||
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 —
|
||||
@@ -635,16 +679,23 @@ final class SyncEngine {
|
||||
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(SplitDocument.self, from: data), doc.isReadable else { return }
|
||||
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.upsertSplit(doc, relativePath: relativePath, into: context)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,8 +712,9 @@ final class SyncEngine {
|
||||
let tombstoned = await tombstones.listStubIDs()
|
||||
let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") }
|
||||
|
||||
var liveSplitIDs = Set<String>()
|
||||
var liveRoutineIDs = Set<String>()
|
||||
var liveWorkoutIDs = Set<String>()
|
||||
var liveScheduleIDs = Set<String>()
|
||||
var unreadablePaths = Set<String>()
|
||||
|
||||
for path in dataFiles {
|
||||
@@ -680,18 +732,25 @@ final class SyncEngine {
|
||||
unreadablePaths.insert(path)
|
||||
continue
|
||||
}
|
||||
// PINNED: routine documents live under "Splits/" on disk (see importFile).
|
||||
if path.hasPrefix("Splits/") {
|
||||
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
|
||||
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 }
|
||||
liveSplitIDs.insert(doc.id)
|
||||
liveRoutineIDs.insert(doc.id)
|
||||
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
|
||||
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,8 +759,8 @@ final class SyncEngine {
|
||||
// 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 splits = try? context.fetch(FetchDescriptor<Split>()) {
|
||||
for s in splits where !liveSplitIDs.contains(s.id)
|
||||
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)
|
||||
@@ -714,6 +773,13 @@ final class SyncEngine {
|
||||
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
|
||||
@@ -728,8 +794,9 @@ final class SyncEngine {
|
||||
// MARK: - Cache deletes
|
||||
|
||||
private func deleteCachedEntity(id: String) {
|
||||
if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
|
||||
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
|
||||
@@ -737,12 +804,15 @@ final class SyncEngine {
|
||||
/// raced the rewrite), and the pending edit's mirror must survive until the
|
||||
/// drain delivers it.
|
||||
private func deleteCachedEntity(jsonRelativePath path: String) {
|
||||
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
||||
splits.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
|
||||
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 {
|
||||
@@ -804,7 +874,7 @@ final class SyncEngine {
|
||||
// Verbatim bundle bytes: byte-identical files across devices make a
|
||||
// same-path conflict (two devices seeding at once) semantically empty.
|
||||
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
||||
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||
} catch {
|
||||
report("Failed to seed \(seed.doc.name)", error)
|
||||
}
|
||||
@@ -837,7 +907,7 @@ final class SyncEngine {
|
||||
/// • live file present, older revision → overwrite with bundle bytes (upgrade)
|
||||
/// • live file present, newer app version → skip (quarantine — never downgrade)
|
||||
/// • no live file, stub present → skip (user deleted it; veto stands)
|
||||
/// • no live file, no stub, name in use → skip (don't duplicate a same-name split)
|
||||
/// • no live file, no stub, name 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
|
||||
@@ -848,17 +918,17 @@ final class SyncEngine {
|
||||
|
||||
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
|
||||
let stubIDs = await tombstones.listStubIDs()
|
||||
let liveSplitNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
|
||||
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: SplitDocument?
|
||||
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(SplitDocument.self, from: data) else {
|
||||
let decoded = try? DocumentCoder.decode(RoutineDocument.self, from: data) else {
|
||||
continue
|
||||
}
|
||||
liveDoc = decoded
|
||||
@@ -869,7 +939,7 @@ final class SyncEngine {
|
||||
hasStub: stubIDs.contains(seed.id)
|
||||
)
|
||||
|
||||
switch SeedReconcilePlanner.decision(for: input, liveSplitNames: liveSplitNames) {
|
||||
switch SeedReconcilePlanner.decision(for: input, liveRoutineNames: liveRoutineNames) {
|
||||
case .skip:
|
||||
continue
|
||||
case .upgrade:
|
||||
@@ -880,7 +950,7 @@ final class SyncEngine {
|
||||
// rewriting the seed bytes here would transiently resurrect the deleted
|
||||
// seed alongside the user's clone. The stub now present vetoes that.
|
||||
// (No name-collision guard here: on a legitimate upgrade the seed itself
|
||||
// is a live split by that name, so a name check would block every upgrade.)
|
||||
// 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).
|
||||
@@ -889,7 +959,7 @@ final class SyncEngine {
|
||||
// Re-check the veto and name guards against fresh state — the metadata
|
||||
// index may have moved since the batch was gathered.
|
||||
if await tombstones.stubExists(id: seed.id) { continue }
|
||||
let names = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
|
||||
let names = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
|
||||
if names.contains(seed.doc.name) { continue }
|
||||
if await writeSeedBytes(seed) { didChange = true }
|
||||
}
|
||||
@@ -902,7 +972,7 @@ final class SyncEngine {
|
||||
}
|
||||
|
||||
/// Force-restore the starter library on explicit user request ("Restore Starter
|
||||
/// Splits"). For each seed with no live file and no live same-name split, lift its
|
||||
/// 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
|
||||
@@ -915,11 +985,11 @@ final class SyncEngine {
|
||||
|
||||
var restored = 0
|
||||
for seed in SeedLibrary.seeds {
|
||||
let liveNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
|
||||
let liveNames = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
|
||||
guard SeedReconcilePlanner.shouldRestore(
|
||||
hasLiveFile: livePaths.contains(seed.doc.relativePath),
|
||||
seedName: seed.doc.name,
|
||||
liveSplitNames: liveNames
|
||||
liveRoutineNames: liveNames
|
||||
) else { continue }
|
||||
|
||||
do {
|
||||
@@ -929,11 +999,11 @@ final class SyncEngine {
|
||||
try await tombstones.removeStub(at: "\(seed.id).json")
|
||||
}
|
||||
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
||||
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||
restored += 1
|
||||
lastSyncError = nil
|
||||
} catch {
|
||||
report("Failed to restore starter split \(seed.doc.name)", error)
|
||||
report("Failed to restore starter routine \(seed.doc.name)", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,11 +1021,11 @@ final class SyncEngine {
|
||||
guard let store else { return false }
|
||||
do {
|
||||
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
||||
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||
lastSyncError = nil
|
||||
return true
|
||||
} catch {
|
||||
report("Failed to reconcile starter split \(seed.doc.name)", error)
|
||||
report("Failed to reconcile starter routine \(seed.doc.name)", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -968,18 +1038,18 @@ final class SyncEngine {
|
||||
}
|
||||
|
||||
/// Scans every live (non-stub) document and builds a plan for exact-content
|
||||
/// duplicate splits/workouts (see `DuplicateCleanupPlanner`). Read-only — no
|
||||
/// 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 could reference any split
|
||||
/// and silently proceeding could misjudge that split as unreferenced.
|
||||
/// 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 splits: [SplitDocument] = []
|
||||
var routines: [RoutineDocument] = []
|
||||
var workouts: [WorkoutDocument] = []
|
||||
var referencedSplitIDs = Set<String>()
|
||||
var referencedRoutineIDs = Set<String>()
|
||||
var failedPaths: [String] = []
|
||||
|
||||
for path in paths {
|
||||
@@ -992,12 +1062,13 @@ final class SyncEngine {
|
||||
continue
|
||||
}
|
||||
|
||||
// PINNED: routine documents live under "Splits/" on disk (see importFile).
|
||||
if path.hasPrefix("Splits/") {
|
||||
do {
|
||||
let doc = try DocumentCoder.decode(SplitDocument.self, from: data)
|
||||
// Quarantined (written by a newer app version) splits are never
|
||||
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 { splits.append(doc) }
|
||||
if doc.isReadable { routines.append(doc) }
|
||||
} catch {
|
||||
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
|
||||
failedPaths.append(path)
|
||||
@@ -1005,17 +1076,29 @@ final class SyncEngine {
|
||||
} else if path.hasPrefix("Workouts/") {
|
||||
do {
|
||||
let doc = try DocumentCoder.decode(WorkoutDocument.self, from: data)
|
||||
// A quarantined workout's splitID still protects that split from
|
||||
// A quarantined workout's routineID still protects that routine from
|
||||
// deletion even though the workout itself is excluded below.
|
||||
if let splitID = doc.splitID {
|
||||
referencedSplitIDs.insert(splitID)
|
||||
referencedSplitIDs.insert(currentSplitID(for: splitID))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,12 +1106,12 @@ final class SyncEngine {
|
||||
throw DuplicateScanError.unreadableFiles(failedPaths.sorted())
|
||||
}
|
||||
|
||||
referencedSplitIDs.formUnion(cloneRedirects.values)
|
||||
referencedRoutineIDs.formUnion(cloneRedirects.values)
|
||||
|
||||
return DuplicateCleanupPlanner.plan(
|
||||
splits: splits,
|
||||
routines: routines,
|
||||
workouts: workouts,
|
||||
referencedSplitIDs: referencedSplitIDs,
|
||||
referencedRoutineIDs: referencedRoutineIDs,
|
||||
isSeed: SeedLibrary.isSeed(id:)
|
||||
)
|
||||
}
|
||||
@@ -1037,32 +1120,36 @@ final class SyncEngine {
|
||||
/// immediately beforehand against the *current* cache state — a watch push or
|
||||
/// another device's write can land between scan and delete — so nothing
|
||||
/// referenced or active is ever removed even if the plan is a few seconds stale.
|
||||
func performCleanup(_ plan: DuplicateCleanupPlan) async -> (splitsDeleted: Int, workoutsDeleted: Int, skipped: Int) {
|
||||
var splitsDeleted = 0
|
||||
func performCleanup(_ plan: DuplicateCleanupPlan) async -> (routinesDeleted: Int, workoutsDeleted: Int, skipped: Int) {
|
||||
var routinesDeleted = 0
|
||||
var workoutsDeleted = 0
|
||||
var skipped = 0
|
||||
|
||||
for group in plan.splitGroups {
|
||||
for group in plan.routineGroups {
|
||||
for doc in group.delete {
|
||||
let splitID = doc.id
|
||||
if SeedLibrary.isSeed(id: splitID) {
|
||||
let routineID = doc.id
|
||||
if SeedLibrary.isSeed(id: routineID) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
let referencingWorkouts = (try? context.fetch(
|
||||
FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == splitID })
|
||||
FetchDescriptor<Workout>(predicate: #Predicate { $0.routineID == routineID })
|
||||
)) ?? []
|
||||
guard referencingWorkouts.isEmpty else {
|
||||
let referencingSchedules = (try? context.fetch(
|
||||
FetchDescriptor<Schedule>(predicate: #Predicate { $0.routineID == routineID })
|
||||
)) ?? []
|
||||
guard referencingWorkouts.isEmpty, referencingSchedules.isEmpty else {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
if let cached = CacheMapper.fetchSplit(id: splitID, in: context) {
|
||||
await softDelete(id: splitID, kind: "split", livePath: cached.jsonRelativePath)
|
||||
// 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: splitID, kind: "split", livePath: doc.relativePath)
|
||||
await softDelete(id: routineID, kind: "split", livePath: doc.relativePath)
|
||||
}
|
||||
deleteCachedEntity(id: splitID)
|
||||
splitsDeleted += 1
|
||||
deleteCachedEntity(id: routineID)
|
||||
routinesDeleted += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,7 +1168,7 @@ final class SyncEngine {
|
||||
}
|
||||
|
||||
saveCacheAndNotify()
|
||||
return (splitsDeleted, workoutsDeleted, skipped)
|
||||
return (routinesDeleted, workoutsDeleted, skipped)
|
||||
}
|
||||
|
||||
// MARK: - Error Reporting
|
||||
|
||||
Reference in New Issue
Block a user