Sync bug fixes across the board!
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m15s

This commit is contained in:
2026-01-09 22:48:20 -07:00
parent d002c703d5
commit f4f4968431
18 changed files with 703 additions and 1269 deletions

View File

@@ -466,7 +466,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -518,7 +518,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -610,7 +610,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -641,7 +641,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;

View File

@@ -388,7 +388,7 @@ struct BackupClimbSession: Codable {
startTime: nil,
endTime: nil,
duration: nil,
status: .finished,
status: .completed,
notes: nil,
isDeleted: true,
createdAt: dateString,

View File

@@ -17,6 +17,7 @@ struct DeltaSyncRequest: Codable {
struct DeltaSyncResponse: Codable {
let serverTime: String
let requestFullSync: Bool?
let gyms: [BackupGym]
let problems: [BackupProblem]
let sessions: [BackupClimbSession]

View File

@@ -71,8 +71,6 @@ class ServerSyncProvider: SyncProvider {
throw SyncError.notConnected
}
// 1. Priority: Delta Sync
// If we have synced before, assume we want to continue with delta sync
if lastSyncTime != nil {
AppLogger.info("Last sync time found, attempting delta sync", tag: logTag)
do {
@@ -81,18 +79,12 @@ class ServerSyncProvider: SyncProvider {
return
} catch {
AppLogger.error("Delta sync failed, falling back to full sync check: \(error)", tag: logTag)
// Fallthrough to full sync logic
}
}
// 2. Full Sync Logic
// Get local backup data
let localBackup = createBackupFromDataManager(dataManager)
// Download server data
let serverBackup = try await downloadData()
// Check if we have any local data
let hasLocalData =
!dataManager.gyms.isEmpty || !dataManager.problems.isEmpty
|| !dataManager.sessions.isEmpty || !dataManager.attempts.isEmpty
@@ -219,7 +211,6 @@ class ServerSyncProvider: SyncProvider {
let formatter = ISO8601DateFormatter()
let lastSyncString = formatter.string(from: lastSync)
// Collect items modified since last sync
var modifiedGyms = dataManager.gyms.filter { gym in
gym.updatedAt > lastSync
}.map { BackupGym(from: $0) }
@@ -249,7 +240,6 @@ class ServerSyncProvider: SyncProvider {
!activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync
}.map { BackupAttempt(from: $0) }
// Handle deleted items as tombstones
let deletedItems = dataManager.getDeletedItems().filter { item in
if let deletedDate = formatter.date(from: item.deletedAt) {
return deletedDate > lastSync
@@ -316,6 +306,11 @@ class ServerSyncProvider: SyncProvider {
let decoder = JSONDecoder()
let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data)
if let requestFullSync = deltaResponse.requestFullSync, requestFullSync {
AppLogger.info("Server requested full sync", tag: logTag)
throw SyncError.serverError(412)
}
AppLogger.info(
"Delta Sync: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count)",
tag: logTag
@@ -337,7 +332,6 @@ class ServerSyncProvider: SyncProvider {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
// 1. Download images for problems that are NOT deleted
var imagePathMapping: [String: String] = [:]
for problem in response.problems {
if let isDeleted = problem.isDeleted, isDeleted { continue }
@@ -359,9 +353,7 @@ class ServerSyncProvider: SyncProvider {
}
}
// 2. Merge Gyms
for backupGym in response.gyms {
// Handle Soft Delete
if let isDeleted = backupGym.isDeleted, isDeleted {
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) {
let existing = dataManager.gyms[index]
@@ -373,7 +365,6 @@ class ServerSyncProvider: SyncProvider {
continue
}
// Handle Update/Insert
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) {
let existing = dataManager.gyms[index]
if let serverUpdate = formatter.date(from: backupGym.updatedAt),
@@ -385,7 +376,6 @@ class ServerSyncProvider: SyncProvider {
}
}
// 3. Merge Problems
for backupProblem in response.problems {
if let isDeleted = backupProblem.isDeleted, isDeleted {
if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == backupProblem.id }) {
@@ -415,7 +405,6 @@ class ServerSyncProvider: SyncProvider {
}
}
// 4. Merge Sessions
for backupSession in response.sessions {
if let isDeleted = backupSession.isDeleted, isDeleted {
if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == backupSession.id }) {
@@ -439,7 +428,6 @@ class ServerSyncProvider: SyncProvider {
}
}
// 5. Merge Attempts
for backupAttempt in response.attempts {
if let isDeleted = backupAttempt.isDeleted, isDeleted {
if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == backupAttempt.id }) {
@@ -477,7 +465,7 @@ class ServerSyncProvider: SyncProvider {
for problem in modifiedProblems {
guard let imagePaths = problem.imagePaths else { continue }
for path in imagePaths {
if let data = ImageManager.shared.getImageData(filename: path) {
if let data = ImageManager.shared.loadImageData(fromPath: path) {
try await uploadImage(filename: path, imageData: data)
}
}
@@ -549,7 +537,7 @@ class ServerSyncProvider: SyncProvider {
private func syncImagesToServer(dataManager: ClimbingDataManager) async throws {
for problem in dataManager.problems {
for path in problem.imagePaths {
if let data = ImageManager.shared.getImageData(filename: path) {
if let data = ImageManager.shared.loadImageData(fromPath: path) {
try await uploadImage(filename: path, imageData: data)
}
}
@@ -568,19 +556,15 @@ class ServerSyncProvider: SyncProvider {
gyms: gyms,
problems: problems,
sessions: sessions,
attempts: attempts,
deletedItems: [] // Legacy field, empty
attempts: attempts
)
}
private func mergeDataSafely(localBackup: ClimbDataBackup, serverBackup: ClimbDataBackup, dataManager: ClimbingDataManager) async throws {
// Basic full merge that prefers server data if newer
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
// Merging Gyms
for gym in serverBackup.gyms {
// Check for soft delete
if let isDeleted = gym.isDeleted, isDeleted {
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == gym.id }) {
let existing = dataManager.gyms[index]
@@ -591,7 +575,6 @@ class ServerSyncProvider: SyncProvider {
continue
}
// Update or Insert
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == gym.id }) {
let existing = dataManager.gyms[index]
if let serverUpdate = formatter.date(from: gym.updatedAt), serverUpdate >= existing.updatedAt {
@@ -602,7 +585,6 @@ class ServerSyncProvider: SyncProvider {
}
}
// Merging Problems
for problem in serverBackup.problems {
if let isDeleted = problem.isDeleted, isDeleted {
if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == problem.id }) {
@@ -624,7 +606,6 @@ class ServerSyncProvider: SyncProvider {
}
}
// Merging Sessions
for session in serverBackup.sessions {
if let isDeleted = session.isDeleted, isDeleted {
if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == session.id }) {
@@ -646,7 +627,6 @@ class ServerSyncProvider: SyncProvider {
}
}
// Merging Attempts
for attempt in serverBackup.attempts {
if let isDeleted = attempt.isDeleted, isDeleted {
if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == attempt.id }) {
@@ -680,7 +660,6 @@ class ServerSyncProvider: SyncProvider {
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
imagePathMapping: [String: String] = [:]
) throws {
// Logic from previous read
let updatedProblems = backup.problems.map { problem in
let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
@@ -688,7 +667,6 @@ class ServerSyncProvider: SyncProvider {
return problem.withUpdatedImagePaths(updatedImagePaths)
}
// Re-construct data, filtering out deleted items (tombstones)
dataManager.gyms = try backup.gyms.compactMap { gym in
if let isDeleted = gym.isDeleted, isDeleted { return nil }
return try gym.toGym()

View File

@@ -1,179 +0,0 @@
import Foundation
struct SyncMerger {
private static let logTag = "SyncMerger"
static func mergeDataSafely(
localBackup: ClimbDataBackup,
serverBackup: ClimbDataBackup,
dataManager: ClimbingDataManager,
imagePathMapping: [String: String]
) throws -> (gyms: [Gym], problems: [Problem], sessions: [ClimbSession], attempts: [Attempt], uniqueDeletions: [DeletedItem]) {
// Merge deletion lists first to prevent resurrection of deleted items
let localDeletions = dataManager.getDeletedItems()
let allDeletions = localDeletions + serverBackup.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
AppLogger.info("Merging gyms...", tag: logTag)
let mergedGyms = mergeGyms(
local: dataManager.gyms,
server: serverBackup.gyms,
deletedItems: uniqueDeletions)
AppLogger.info("Merging problems...", tag: logTag)
let mergedProblems = try mergeProblems(
local: dataManager.problems,
server: serverBackup.problems,
imagePathMapping: imagePathMapping,
deletedItems: uniqueDeletions)
AppLogger.info("Merging sessions...", tag: logTag)
let mergedSessions = try mergeSessions(
local: dataManager.sessions,
server: serverBackup.sessions,
deletedItems: uniqueDeletions)
AppLogger.info("Merging attempts...", tag: logTag)
let mergedAttempts = try mergeAttempts(
local: dataManager.attempts,
server: serverBackup.attempts,
deletedItems: uniqueDeletions)
return (mergedGyms, mergedProblems, mergedSessions, mergedAttempts, uniqueDeletions)
}
private static func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym] {
var merged = local
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
let localGymIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverGym in server {
if let serverGymConverted = try? serverGym.toGym() {
let localHasGym = localGymIds.contains(serverGym.id)
let isDeleted = deletedGymIds.contains(serverGym.id)
if !localHasGym && !isDeleted {
merged.append(serverGymConverted)
}
}
}
return merged
}
private static func mergeProblems(
local: [Problem],
server: [BackupProblem],
imagePathMapping: [String: String],
deletedItems: [DeletedItem]
) throws -> [Problem] {
var merged = local
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
let localProblemIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
for serverProblem in server {
let localHasProblem = localProblemIds.contains(serverProblem.id)
let isDeleted = deletedProblemIds.contains(serverProblem.id)
if !localHasProblem && !isDeleted {
var problemToAdd = serverProblem
if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths, !imagePaths.isEmpty {
let updatedImagePaths = imagePaths.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
if updatedImagePaths != imagePaths {
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
}
if let serverProblemConverted = try? problemToAdd.toProblem() {
merged.append(serverProblemConverted)
}
}
}
return merged
}
private static func mergeSessions(
local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem]
) throws -> [ClimbSession] {
var merged = local
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
let localSessionIds = Set(local.map { $0.id.uuidString })
merged.removeAll { session in
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
}
for serverSession in server {
let localHasSession = localSessionIds.contains(serverSession.id)
let isDeleted = deletedSessionIds.contains(serverSession.id)
if !localHasSession && !isDeleted {
if let serverSessionConverted = try? serverSession.toClimbSession() {
merged.append(serverSessionConverted)
}
}
}
return merged
}
private static func mergeAttempts(
local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem]
) throws -> [Attempt] {
var merged = local
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
let localAttemptIds = Set(local.map { $0.id.uuidString })
// Get active session IDs to protect their attempts
let activeSessionIds = Set(
local.compactMap { attempt in
return attempt.sessionId
}.filter { _ in
return true
})
// Remove items that were deleted on other devices (but be conservative with attempts)
merged.removeAll { attempt in
deletedAttemptIds.contains(attempt.id.uuidString)
&& !activeSessionIds.contains(attempt.sessionId)
}
for serverAttempt in server {
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
if !localHasAttempt && !isDeleted {
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
merged.append(serverAttemptConverted)
}
}
}
return merged
}
}

View File

@@ -631,6 +631,34 @@ class ClimbingDataManager: ObservableObject {
userDefaults.removeObject(forKey: Keys.deletedItems)
}
func cleanupOldDeletions() {
guard let data = userDefaults.data(forKey: Keys.deletedItems),
let deletions = try? decoder.decode([DeletedItem].self, from: data)
else {
return
}
let cutoffDate = Date().addingTimeInterval(-90 * 24 * 60 * 60) // 90 days ago
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let validDeletions = deletions.filter { item in
if let date = formatter.date(from: item.deletedAt) {
return date > cutoffDate
}
return false
}
if validDeletions.count < deletions.count {
if let encodedData = try? encoder.encode(validDeletions) {
userDefaults.set(encodedData, forKey: Keys.deletedItems)
AppLogger.info(
"Cleaned up \(deletions.count - validDeletions.count) old deletion records",
tag: "ClimbingDataManager")
}
}
}
func attempts(forProblem problemId: UUID) -> [Attempt] {
return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
}
@@ -669,6 +697,7 @@ class ClimbingDataManager: ObservableObject {
}
private func cleanupOrphanedData() {
cleanupOldDeletions()
let validSessionIds = Set(sessions.map { $0.id })
let validProblemIds = Set(problems.map { $0.id })
let validGymIds = Set(gyms.map { $0.id })