Fix rebucketing, seed upgrades, watch pruning, end re-stamping; add model tests

Editing a workout's start date now removes the file at its old month
bucket so the record no longer duplicates on the next reconcile. Seed
reconcile re-checks the tombstone veto before overwriting an upgraded
seed. The watch applies authoritative-empty pushes so remote deletes
prune, and a re-saved finished workout keeps its original end time.
Adds unit tests for the mappers, path bucketing, and status machine.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-08 07:54:10 -04:00
parent a93cbc30b7
commit 06fa52345b
11 changed files with 697 additions and 8 deletions
@@ -77,9 +77,9 @@
"durationSeconds" : 0,
"id" : "01DXF6DT00MJH9N622QTMM7XB3",
"loadType" : 0,
"name" : "Bird Dog",
"name" : "Crunch",
"order" : 7,
"reps" : 8,
"reps" : 12,
"sets" : 3,
"weight" : 0
},
+23 -2
View File
@@ -331,9 +331,21 @@ final class SyncEngine {
func save(workout doc: WorkoutDocument) async {
guard let store else { return }
// 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, so
// we can remove the stale file below 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
do {
try await store.write(doc, to: doc.relativePath)
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
// Same id, new path: drop the orphaned file at the old bucket. The id lives
// on at the new path, so this is a plain removal no tombstone (a tombstone
// would veto the record that just moved). Phone stays the sole writer.
if let previousPath, previousPath != doc.relativePath {
try? await store.remove(at: previousPath)
}
saveCacheAndNotify()
lastSyncError = nil
} catch {
@@ -619,8 +631,17 @@ final class SyncEngine {
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 split 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
// always safe (the file can only ever be an older seed revision).
// 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
@@ -827,7 +848,7 @@ final class SyncEngine {
/// 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
print("[Sync] \(full)")
log.error("\(full, privacy: .public)")
lastSyncError = full
}
}
+3 -1
View File
@@ -132,7 +132,8 @@ struct SettingsView: View {
Text("iCloud Sync")
}
// MARK: - Developer Section
// MARK: - Developer Section (debug builds only)
#if DEBUG
Section {
NavigationLink {
DuplicateCleanupView()
@@ -142,6 +143,7 @@ struct SettingsView: View {
} header: {
Text("Developer")
}
#endif
// MARK: - About Section
Section {