import Foundation import SwiftData // Stateless translation between the on-disk `*Document` wire format and the // SwiftData cache entities. The only code that knows both shapes. // // • `init(from: entity)` — cache → document. Used by the write path (build a // document to save) and the iPhone↔Watch bridge. // • `CacheMapper.upsert*` — document → cache. Used ONLY by the metadata // observer (the sole cache mutator) and cache rebuilds. Embedded children // are reconciled by id (update / insert / delete) so frequent in-workout // edits don't churn unrelated rows. // MARK: - Cache → Document extension ExerciseDocument { init(from e: Exercise) { self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps, weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds, machineSettings: e.machineSettings) } } extension RoutineDocument { init(from routine: Routine) { self.init(schemaVersion: Self.currentSchemaVersion, id: routine.id, name: routine.name, color: routine.color, systemImage: routine.systemImage, order: routine.order, createdAt: routine.createdAt, updatedAt: routine.updatedAt, exercises: routine.exercisesArray.map(ExerciseDocument.init(from:)), activityType: routine.activityTypeRaw, restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance) } } extension WorkoutLogDocument { init(from log: WorkoutLog) { self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets, reps: log.reps, weight: log.weight, loadType: log.loadType, durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex, status: log.statusRaw, notes: log.notes, date: log.date, machineSettings: log.machineSettings, startedAt: log.startedAt, completedAt: log.completedAt, setEntries: log.setEntries, updatedAt: log.logUpdatedAt) } } extension WorkoutDocument { init(from workout: Workout) { self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, routineID: workout.routineID, routineName: workout.routineName, start: workout.start, end: workout.end, status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt, logs: workout.logsArray.map(WorkoutLogDocument.init(from:)), metrics: workout.metrics, deletedLogIDs: workout.deletedLogIDs, restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance) } /// Maps a *live* cache entity to a document, or `nil` when that entity has already /// been deleted from its SwiftData context. Reading a persisted property on a /// deleted `@Model` traps (`_InitialBackingData.getValue` → `assertionFailure`), and /// a closure-based `NavigationLink` builds its destination view — and so runs this /// map — eagerly for *every* row in the parent list, including during the view-graph /// update that fires the instant a workout is deleted. Any map of an entity not /// already known to be live must go through this guard. /// /// `isDeleted` alone is not enough: it is true only between `context.delete()` and /// `context.save()`. Once the deletion is saved the model becomes *unregistered* — /// `isDeleted` reads false again, `modelContext` goes nil, and any persisted-property /// read still traps. A `@Model` retained across time (not freshly fetched) can reach /// this map in that state, so check both. init?(fromLive workout: Workout) { guard !workout.isDeleted, workout.modelContext != nil else { return nil } self.init(from: workout) } /// An inert stand-in for a destination whose backing entity is gone: the row is /// being removed, so its detail screen is never actually shown. Reads nothing from /// any entity, so it is safe to substitute for a deleted `@Model`. static let deletedPlaceholder = WorkoutDocument( schemaVersion: WorkoutDocument.currentSchemaVersion, id: "", start: Date(timeIntervalSinceReferenceDate: 0), status: WorkoutStatus.notStarted.rawValue, createdAt: Date(timeIntervalSinceReferenceDate: 0), updatedAt: Date(timeIntervalSinceReferenceDate: 0), logs: []) } extension ScheduleDocument { init(from schedule: Schedule) { self.init(schemaVersion: Self.currentSchemaVersion, id: schedule.id, routineID: schedule.routineID, routineName: schedule.routineName, goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw, weekdays: schedule.weekdays, date: schedule.date, reminderMinutes: schedule.reminderMinutes, order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt) } } // MARK: - Document → Cache (upsert) enum CacheMapper { static func fetchRoutine(id: String, in context: ModelContext) -> Routine? { var d = FetchDescriptor(predicate: #Predicate { $0.id == id }) d.fetchLimit = 1 return try? context.fetch(d).first } static func fetchWorkout(id: String, in context: ModelContext) -> Workout? { var d = FetchDescriptor(predicate: #Predicate { $0.id == id }) d.fetchLimit = 1 return try? context.fetch(d).first } static func fetchSchedule(id: String, in context: ModelContext) -> Schedule? { var d = FetchDescriptor(predicate: #Predicate { $0.id == id }) d.fetchLimit = 1 return try? context.fetch(d).first } // MARK: Routine static func upsertRoutine(_ doc: RoutineDocument, relativePath: String, into context: ModelContext) { let routine: Routine if let existing = fetchRoutine(id: doc.id, in: context) { routine = existing } else { routine = Routine(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage, order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt, jsonRelativePath: relativePath) context.insert(routine) } routine.name = doc.name routine.color = doc.color routine.systemImage = doc.systemImage routine.order = doc.order routine.createdAt = doc.createdAt routine.updatedAt = doc.updatedAt routine.jsonRelativePath = relativePath routine.activityTypeRaw = doc.activityType ?? 0 routine.restSeconds = doc.restSeconds routine.autoAdvance = doc.autoAdvance let existing = Dictionary(routine.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) var keep = Set() for ed in doc.exercises { keep.insert(ed.id) if let e = existing[ed.id] { apply(ed, to: e) } else { let e = makeExercise(ed) e.routine = routine context.insert(e) } } for e in Array(routine.exercises) where !keep.contains(e.id) { context.delete(e) } } private static func makeExercise(_ d: ExerciseDocument) -> Exercise { Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds, machineSettings: d.machineSettings) } private static func apply(_ d: ExerciseDocument, to e: Exercise) { e.name = d.name e.order = d.order e.sets = d.sets e.reps = d.reps e.weight = d.weight e.loadType = d.loadType e.durationTotalSeconds = d.durationSeconds e.machineSettings = d.machineSettings } // MARK: Workout static func upsertWorkout(_ doc: WorkoutDocument, relativePath: String, into context: ModelContext) { let workout: Workout if let existing = fetchWorkout(id: doc.id, in: context) { workout = existing } else { workout = Workout(id: doc.id, routineID: doc.routineID, routineName: doc.routineName, start: doc.start, end: doc.end, statusRaw: doc.status, createdAt: doc.createdAt, updatedAt: doc.updatedAt, jsonRelativePath: relativePath) context.insert(workout) } workout.routineID = doc.routineID workout.routineName = doc.routineName workout.start = doc.start workout.end = doc.end workout.statusRaw = doc.status workout.createdAt = doc.createdAt workout.updatedAt = doc.updatedAt workout.jsonRelativePath = relativePath workout.metrics = doc.metrics workout.deletedLogIDs = doc.deletedLogIDs workout.restSeconds = doc.restSeconds workout.autoAdvance = doc.autoAdvance let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) var keep = Set() for ld in doc.logs { keep.insert(ld.id) if let l = existing[ld.id] { apply(ld, to: l) } else { let l = makeLog(ld) l.workout = workout context.insert(l) } } for l in Array(workout.logs) where !keep.contains(l.id) { context.delete(l) } } private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog { WorkoutLog(id: d.id, exerciseName: d.exerciseName, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds, currentStateIndex: d.currentStateIndex, statusRaw: d.status, notes: d.notes, date: d.date, machineSettings: d.machineSettings, startedAt: d.startedAt, completedAt: d.completedAt, setEntries: d.setEntries, logUpdatedAt: d.updatedAt) } private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) { l.exerciseName = d.exerciseName l.order = d.order l.sets = d.sets l.reps = d.reps l.weight = d.weight l.loadType = d.loadType l.durationTotalSeconds = d.durationSeconds l.currentStateIndex = d.currentStateIndex l.statusRaw = d.status l.notes = d.notes l.date = d.date l.machineSettings = d.machineSettings l.startedAt = d.startedAt l.completedAt = d.completedAt l.setEntries = d.setEntries l.logUpdatedAt = d.updatedAt } // MARK: Schedule static func upsertSchedule(_ doc: ScheduleDocument, relativePath: String, into context: ModelContext) { let schedule: Schedule if let existing = fetchSchedule(id: doc.id, in: context) { schedule = existing } else { schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName, goalRaw: doc.goal, recurrenceRaw: doc.recurrence, weekdays: doc.weekdays, date: doc.date, reminderMinutes: doc.reminderMinutes, order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt, jsonRelativePath: relativePath) context.insert(schedule) } schedule.routineID = doc.routineID schedule.routineName = doc.routineName schedule.goalRaw = doc.goal schedule.recurrenceRaw = doc.recurrence schedule.weekdays = doc.weekdays schedule.date = doc.date schedule.reminderMinutes = doc.reminderMinutes schedule.order = doc.order schedule.createdAt = doc.createdAt schedule.updatedAt = doc.updatedAt schedule.jsonRelativePath = relativePath } }