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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user