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