Keep schedules attached to routines across seed clone-on-edit forks

Editing a starter-seed routine forks it to a fresh ULID, but only workouts
were durably repointed — schedules kept the dead seed id, and after a
relaunch (empty in-memory redirect map) the Today board and edit form saw
the routine as gone.

- cloneSeedOnEdit now repoints schedules too (routineID + routineName
  track the clone; a schedule's name is a live-pointer fallback, unlike a
  workout's frozen run-as name)
- A launch-time repair pass (pure ScheduleRepairPlanner + tests) heals
  already-broken references by re-attaching a dead-pointer schedule to
  the unique live routine matching its remembered name
This commit is contained in:
2026-07-11 13:02:55 -04:00
parent 76150070f8
commit e509d0e7c5
3 changed files with 239 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
//
// ScheduleRepair.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
// Pure decision logic for repairing schedules whose `routineID` no longer resolves
// to any live routine (precedent: `SeedReconcilePlanner` / `ReminderPlanner`). No I/O
// and no `SyncEngine` dependency: the engine snapshots the schedule and routine
// caches, and applies the returned repairs.
//
// Why this exists: before schedules were repointed at seed-fork time, editing a
// starter-seed routine soft-deleted the seed and left every schedule pointing at the
// dead fixed ULID permanently, since the in-memory `cloneRedirects` map is empty
// after a relaunch. Fork-time repointing fixes new forks; this repair pass heals the
// references existing user data already broke. It's cheap and idempotent, so it runs
// on every launch after the cache is reconciled.
// MARK: - Snapshots
/// A plain (id, routineID, routineName) snapshot of a `Schedule` cache entity.
struct ScheduleRepairRef: Sendable, Equatable {
var id: String
var routineID: String
var routineName: String
}
/// A plain (id, name) snapshot of a live `Routine` cache entity.
struct RoutineRepairRef: Sendable, Equatable {
var id: String
var name: String
}
/// One repair to apply: rewrite this schedule's `routineID` to the matched routine.
struct ScheduleRepair: Sendable, Equatable {
var scheduleID: String
var newRoutineID: String
}
// MARK: - Planner
enum ScheduleRepairPlanner {
/// The repairs for the given caches. A schedule needs repair when its `routineID`
/// matches no live routine; the repair target is the live routine whose name equals
/// the schedule's denormalized `routineName` but **only when exactly one** live
/// routine carries that name. Zero matches (routine truly gone) or several (the
/// match would be a guess) leave the schedule alone the edit form's
/// "no longer available" flow is the deliberate fallback there.
static func plans(
schedules: [ScheduleRepairRef],
liveRoutines: [RoutineRepairRef]
) -> [ScheduleRepair] {
let liveIDs = Set(liveRoutines.map(\.id))
// Routine name its sole live id, with names carried by several routines
// tracked separately (an ambiguous name must never be a repair target).
var idByName: [String: String] = [:]
var ambiguousNames: Set<String> = []
for routine in liveRoutines {
if idByName[routine.name] != nil {
ambiguousNames.insert(routine.name)
} else {
idByName[routine.name] = routine.id
}
}
return schedules.compactMap { schedule in
guard !liveIDs.contains(schedule.routineID),
!ambiguousNames.contains(schedule.routineName),
let newID = idByName[schedule.routineName]
else { return nil }
return ScheduleRepair(scheduleID: schedule.id, newRoutineID: newID)
}
}
}
+73
View File
@@ -386,6 +386,7 @@ final class SyncEngine {
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 {
@@ -421,6 +422,34 @@ final class SyncEngine {
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
@@ -898,6 +927,50 @@ final class SyncEngine {
} 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
@@ -0,0 +1,88 @@
import Foundation
import Testing
@testable import Workouts
/// Locks `ScheduleRepairPlanner`'s pure decision rules schedule/routine cache
/// snapshots in, [(scheduleID, newRoutineID)] out. The engine's apply pass (file
/// rewrite + cache upsert + single notify) is deliberately untested here; the
/// planner carries all the judgment.
struct ScheduleRepairPlannerTests {
private func schedule(id: String = "S1", routineID: String, routineName: String) -> ScheduleRepairRef {
ScheduleRepairRef(id: id, routineID: routineID, routineName: routineName)
}
private func routine(id: String, name: String) -> RoutineRepairRef {
RoutineRepairRef(id: id, name: name)
}
// MARK: - Repairs
@Test func brokenReferenceWithUniqueNameMatchIsRepaired() {
let repairs = ScheduleRepairPlanner.plans(
schedules: [schedule(routineID: "DEAD-SEED", routineName: "Cardio")],
liveRoutines: [routine(id: "CLONE-1", name: "Cardio"),
routine(id: "R-2", name: "Upper Body")])
#expect(repairs == [ScheduleRepair(scheduleID: "S1", newRoutineID: "CLONE-1")])
}
@Test func multipleBrokenSchedulesEachRepairToTheirOwnNameMatch() {
let repairs = ScheduleRepairPlanner.plans(
schedules: [
schedule(id: "S1", routineID: "DEAD-A", routineName: "Cardio"),
schedule(id: "S2", routineID: "DEAD-B", routineName: "Upper Body"),
],
liveRoutines: [routine(id: "R-1", name: "Cardio"),
routine(id: "R-2", name: "Upper Body")])
#expect(repairs == [
ScheduleRepair(scheduleID: "S1", newRoutineID: "R-1"),
ScheduleRepair(scheduleID: "S2", newRoutineID: "R-2"),
])
}
// MARK: - Left alone
@Test func brokenReferenceWithNoNameMatchIsUntouched() {
let repairs = ScheduleRepairPlanner.plans(
schedules: [schedule(routineID: "DEAD-SEED", routineName: "Cardio")],
liveRoutines: [routine(id: "R-2", name: "Upper Body")])
#expect(repairs.isEmpty)
}
@Test func brokenReferenceWithTwoSameNameLiveRoutinesIsUntouched() {
// Two live routines both named "Cardio" the match would be a guess, so the
// schedule is left for the edit form's "no longer available" flow.
let repairs = ScheduleRepairPlanner.plans(
schedules: [schedule(routineID: "DEAD-SEED", routineName: "Cardio")],
liveRoutines: [routine(id: "R-1", name: "Cardio"),
routine(id: "R-2", name: "Cardio")])
#expect(repairs.isEmpty)
}
@Test func healthyReferenceIsUntouchedEvenWhenAnotherRoutineSharesTheName() {
// The schedule's routineID resolves fine a same-name routine elsewhere must
// not lure it away.
let repairs = ScheduleRepairPlanner.plans(
schedules: [schedule(routineID: "R-1", routineName: "Cardio")],
liveRoutines: [routine(id: "R-1", name: "Cardio"),
routine(id: "R-9", name: "Cardio")])
#expect(repairs.isEmpty)
}
// MARK: - Degenerate inputs
@Test func emptyInputsProduceNoRepairs() {
#expect(ScheduleRepairPlanner.plans(schedules: [], liveRoutines: []).isEmpty)
#expect(ScheduleRepairPlanner.plans(
schedules: [], liveRoutines: [routine(id: "R-1", name: "Cardio")]).isEmpty)
// Broken schedule, no live routines at all nothing to repair to.
#expect(ScheduleRepairPlanner.plans(
schedules: [schedule(routineID: "DEAD", routineName: "Cardio")],
liveRoutines: []).isEmpty)
}
}