From 375e13718efec293a6c08d220c4f086a424f9c56 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 16 Aug 2026 16:31:37 +0530 Subject: [PATCH] feat(ios): merge core for iCloud sync, without any transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Training currently lives on one device, which every public surface now admits. This is the first half of fixing that: the merge, built and tested before any CloudKit code exists, because the merge is the only part of syncing that can silently destroy recorded training. Pure functions with an injected clock, so the rules can be reviewed and tested without a container or a network. Taking per-record CloudKit rather than SwiftData+CloudKit, which is the recommendation on #44 and is stated there as an assumption rather than a settled decision. A whole-document copy would force a person to choose between two versions of their training whenever two devices both wrote — a decision nobody can make correctly mid-workout. Per record the common cases resolve themselves. Rules, each with a test: - History is append-only and never resolves by time, so two phones that recorded different sessions end up with both, and a device with a wrong clock or one that never saw a workout cannot erase it. A session tombstone cannot win. - Templates and goals are last-writer-wins, with tombstones, so a delete propagates instead of the other device pushing the entity straight back. - Timestamp ties break on payload bytes, so two devices merging the same pair reach the same answer rather than disagreeing forever. - The active session is never synced. A workout in progress belongs to the phone in your hand. - Bundled templates are not records; shipping content should not travel through somebody's iCloud account. - Merging an already merged result proposes no further work. The round-trip test caught a real defect: `.iso8601` truncates to whole seconds, so a record did not decode back to the value it was encoded from. Every record would have looked freshly edited on each trip, and two edits inside one second would have tied on timestamp and fallen through to the arbitrary tie-break. Sync payloads now encode dates exactly. The export keeps ISO 8601, which is right for a file a person may open. `WorkoutTemplate` and `ExerciseGoal` deliberately gained no `updatedAt`. That puts sync bookkeeping inside the training model, where every mutation site has to maintain it and will be wrong the first time one forgets. A ledger fingerprints the encoded payload instead, which cannot be forgotten. No caller yet, by design. Transport, the iCloud entitlement, ledger persistence and real `CKAccountStatus` in Settings remain on #44, and sync stays unshipped until two-device convergence is verified on hardware. Settings still says sync is not active, which is still true. Native gate: 80 unit and 11 UI tests pass, release build succeeds, coverage 84.3236%. Duplication zero, complexity unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- ios/Setline.xcodeproj/project.pbxproj | 20 ++ ios/Sources/SetlineCore/Sync/SyncEngine.swift | 259 ++++++++++++++++++ ios/Sources/SetlineCore/Sync/SyncRecord.swift | 103 +++++++ .../SetlineCoreTests/SyncEngineTests.swift | 252 +++++++++++++++++ 4 files changed, 634 insertions(+) create mode 100644 ios/Sources/SetlineCore/Sync/SyncEngine.swift create mode 100644 ios/Sources/SetlineCore/Sync/SyncRecord.swift create mode 100644 ios/Tests/SetlineCoreTests/SyncEngineTests.swift diff --git a/ios/Setline.xcodeproj/project.pbxproj b/ios/Setline.xcodeproj/project.pbxproj index e468508..02e6135 100644 --- a/ios/Setline.xcodeproj/project.pbxproj +++ b/ios/Setline.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 171B1B9E0DDE084379BB9DF8 /* RestNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B9735779088EF43C24F335 /* RestNotifier.swift */; }; 1FA5DF9F89B308A82207E40B /* SetlineCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 309CBBBA2928642CBD999F67 /* Progression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 594DC48A6CD7B68259549A12 /* Progression.swift */; }; + 3120400F1A39A6AE513A7DDA /* SyncRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9405EFEDE8A70CC15F9E0309 /* SyncRecord.swift */; }; 326CD8AE0B8D86B2954E301E /* PlanViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396D4D1100A1E0FACCD8953A /* PlanViews.swift */; }; 3BBA35B8612998A8EB3205F2 /* ExerciseCatalogue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602F0E123ED63C93EDDBA179 /* ExerciseCatalogue.swift */; }; 3CE2DEDE101DA826A45AC5F8 /* Design.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2CB1821B5B5AE4C75873C6 /* Design.swift */; }; @@ -24,12 +25,14 @@ 8AEB42E3793E01E4E40F67FF /* SetlineCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; }; 8CCC1E8C5F455AF8C9B8EFD7 /* ExercisesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D9600ED385C5C29CB0702D3 /* ExercisesView.swift */; }; 905B35BB3B49C692703FAA7B /* TodayResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6AEE25CFD26FE63620A033 /* TodayResolution.swift */; }; + 9B6BAEA361E4A2746BFBF940 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D57699DA4317A7229DA6B21 /* SyncEngine.swift */; }; A748F947702FBF73CBE681AD /* WorkoutPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818A8EDA05B5D0B4E9DE47B3 /* WorkoutPlayerView.swift */; }; A949A76428AB4118FB259A70 /* SecondaryViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */; }; BE5FDC83C2B64F526F64979C /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824EFFB1023C857CE4F1FE1C /* RootView.swift */; }; C66AD84C2D8FAD24149B2445 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */; }; C7E1EDD15D038B815CB78D27 /* SetlineApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608F81A574360691A58B8581 /* SetlineApp.swift */; }; D9647D921553DE22AB36226A /* SetlineCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DEE8DD0CE56008BF90B164DB /* SyncEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC052D7878745A06AC98A7E9 /* SyncEngineTests.swift */; }; E5FAA9DE6E6F5CCBE9E4343F /* Goals.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02194DFCF1365CCD34525231 /* Goals.swift */; }; E85DFE444A157EC32C671B9B /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B839442BFCCB000A563CD704 /* AppModel.swift */; }; EC589E605EEE16DA5E0F613E /* SetlineCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; }; @@ -96,6 +99,7 @@ 40CADB4F69054A14B366A16D /* Setline.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Setline.entitlements; sourceTree = ""; }; 4833E444B40BC982AC57AACC /* TwelveWeekProgramme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwelveWeekProgramme.swift; sourceTree = ""; }; 594DC48A6CD7B68259549A12 /* Progression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Progression.swift; sourceTree = ""; }; + 5D57699DA4317A7229DA6B21 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = ""; }; 5D9600ED385C5C29CB0702D3 /* ExercisesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesView.swift; sourceTree = ""; }; 602F0E123ED63C93EDDBA179 /* ExerciseCatalogue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExerciseCatalogue.swift; sourceTree = ""; }; 608F81A574360691A58B8581 /* SetlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineApp.swift; sourceTree = ""; }; @@ -104,6 +108,7 @@ 6E6AEE25CFD26FE63620A033 /* TodayResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayResolution.swift; sourceTree = ""; }; 818A8EDA05B5D0B4E9DE47B3 /* WorkoutPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutPlayerView.swift; sourceTree = ""; }; 824EFFB1023C857CE4F1FE1C /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + 9405EFEDE8A70CC15F9E0309 /* SyncRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncRecord.swift; sourceTree = ""; }; B0859DE334CC4D97FFE0DFDA /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; B1F5DA4EB37759476E57B69F /* SetlineUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = SetlineUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B7B0A3E4EF45F3C038E3C265 /* SetlineCoreTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = SetlineCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -111,6 +116,7 @@ B888AC3A36F08B6334AE20CA /* Targets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Targets.swift; sourceTree = ""; }; BA87F29CCAE78D4F24E75B1F /* Setline.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Setline.app; sourceTree = BUILT_PRODUCTS_DIR; }; C9B9735779088EF43C24F335 /* RestNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestNotifier.swift; sourceTree = ""; }; + CC052D7878745A06AC98A7E9 /* SyncEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngineTests.swift; sourceTree = ""; }; CE77BBBECF6D7ED1F7E8047E /* SetEntryParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetEntryParser.swift; sourceTree = ""; }; DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryViews.swift; sourceTree = ""; }; F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -190,11 +196,21 @@ B888AC3A36F08B6334AE20CA /* Targets.swift */, 6E6AEE25CFD26FE63620A033 /* TodayResolution.swift */, 4833E444B40BC982AC57AACC /* TwelveWeekProgramme.swift */, + 7F11BB8EB61A5F8FAC4C3B95 /* Sync */, ); name = SetlineCore; path = Sources/SetlineCore; sourceTree = ""; }; + 7F11BB8EB61A5F8FAC4C3B95 /* Sync */ = { + isa = PBXGroup; + children = ( + 5D57699DA4317A7229DA6B21 /* SyncEngine.swift */, + 9405EFEDE8A70CC15F9E0309 /* SyncRecord.swift */, + ); + path = Sync; + sourceTree = ""; + }; C93C5A86494CB1CE017F1623 /* Products */ = { isa = PBXGroup; children = ( @@ -219,6 +235,7 @@ isa = PBXGroup; children = ( 1211B40E359F4BBD82A557A9 /* SetlineCoreTests.swift */, + CC052D7878745A06AC98A7E9 /* SyncEngineTests.swift */, ); name = SetlineCoreTests; path = Tests/SetlineCoreTests; @@ -396,6 +413,7 @@ buildActionMask = 2147483647; files = ( 677B5B2EB644215DBE6CE4D0 /* SetlineCoreTests.swift in Sources */, + DEE8DD0CE56008BF90B164DB /* SyncEngineTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -409,6 +427,8 @@ F5D0CC71C826F541575E7D99 /* Persistence.swift in Sources */, 309CBBBA2928642CBD999F67 /* Progression.swift in Sources */, 401D75E9685D1937CEA9AF16 /* SetEntryParser.swift in Sources */, + 9B6BAEA361E4A2746BFBF940 /* SyncEngine.swift in Sources */, + 3120400F1A39A6AE513A7DDA /* SyncRecord.swift in Sources */, 0FA5FD50B707EF422848A2E6 /* Targets.swift in Sources */, 905B35BB3B49C692703FAA7B /* TodayResolution.swift in Sources */, 5FDD1235ADE7D25DC029C4F6 /* TwelveWeekProgramme.swift in Sources */, diff --git a/ios/Sources/SetlineCore/Sync/SyncEngine.swift b/ios/Sources/SetlineCore/Sync/SyncEngine.swift new file mode 100644 index 0000000..4393801 --- /dev/null +++ b/ios/Sources/SetlineCore/Sync/SyncEngine.swift @@ -0,0 +1,259 @@ +import Foundation + +/// Turns a document into records, merges records from two devices, and turns the +/// result back into a document. +/// +/// Every function here is pure. The merge is the part of syncing that can lose +/// somebody's training, so it is deliberately separated from anything that touches +/// the network, a container, or a clock it does not control: `now` is always passed +/// in. CloudKit transport sits on top of this and holds no merge rules of its own. +public enum SyncEngine { + /// The outcome of reconciling local and remote records. + public struct MergeResult: Equatable, Sendable { + /// Everything that should exist after the merge, on both sides. + public var merged: [SyncRecord] + /// Records the remote is missing or holds an older version of. + public var toPush: [SyncRecord] + /// Records the local side is missing or holds an older version of. + public var toPull: [SyncRecord] + + public init(merged: [SyncRecord], toPush: [SyncRecord], toPull: [SyncRecord]) { + self.merged = merged + self.toPush = toPush + self.toPull = toPull + } + + public var isUpToDate: Bool { toPush.isEmpty && toPull.isEmpty } + } + + // MARK: - Document to records + + /// Encodes a document as records, dating each one through the ledger. + /// + /// The active session is deliberately excluded. A workout in progress belongs + /// to the phone in your hand: syncing it would let a second device advance or + /// finish a session you are still doing. + public static func records( + for document: SetlineDocument, + ledger: inout SyncLedger, + now: Date + ) throws -> [SyncRecord] { + let encoder = makeEncoder() + var records: [SyncRecord] = [] + + for template in document.templates where !template.isBundled { + records.append( + SyncRecord( + kind: .template, + entityID: template.id, + modifiedAt: now, + payload: try encoder.encode(template) + ) + ) + } + for session in document.history { + records.append( + SyncRecord( + kind: .session, + entityID: session.id, + modifiedAt: now, + payload: try encoder.encode(session) + ) + ) + } + for goal in document.goals { + records.append( + SyncRecord( + kind: .goal, + entityID: goal.id, + modifiedAt: now, + payload: try encoder.encode(goal) + ) + ) + } + records.append( + SyncRecord( + kind: .programme, + entityID: SyncRecordKind.singletonID, + modifiedAt: now, + payload: try encoder.encode(document.programme) + ) + ) + + // Stamp after building, so an unchanged payload keeps its original date. + return records.map { record in + var dated = record + dated.modifiedAt = ledger.stamp(record, now: now) + return dated + } + } + + /// Records for entities that existed at the last sync and are now gone. + /// + /// Without these a delete never propagates: the other device still holds the + /// entity and pushes it straight back. Bundled templates are never records, and + /// history is append-only, so neither can produce a tombstone. + public static func tombstones( + for document: SetlineDocument, + ledger: inout SyncLedger, + now: Date + ) -> [SyncRecord] { + var live: Set = [] + for template in document.templates where !template.isBundled { + live.insert(SyncRecord.recordName(kind: .template, entityID: template.id)) + } + for goal in document.goals { + live.insert(SyncRecord.recordName(kind: .goal, entityID: goal.id)) + } + + var tombstones: [SyncRecord] = [] + for (recordName, stamp) in ledger.stamps { + guard let (kind, entityID) = parse(recordName) else { continue } + guard kind == .template || kind == .goal else { continue } + guard !live.contains(recordName) else { continue } + guard stamp.fingerprint != "deleted" else { continue } + var tombstone = SyncRecord(kind: kind, entityID: entityID, modifiedAt: now, payload: nil) + tombstone.modifiedAt = ledger.stamp(tombstone, now: now) + tombstones.append(tombstone) + } + return tombstones.sorted { $0.recordName < $1.recordName } + } + + // MARK: - Merge + + /// Reconciles two sets of records without ever dropping recorded training. + public static func merge(local: [SyncRecord], remote: [SyncRecord]) -> MergeResult { + var merged: [String: SyncRecord] = [:] + var toPush: [SyncRecord] = [] + var toPull: [SyncRecord] = [] + + let localByName = Dictionary(local.map { ($0.recordName, $0) }, uniquingKeysWith: winner) + let remoteByName = Dictionary(remote.map { ($0.recordName, $0) }, uniquingKeysWith: winner) + + for name in Set(localByName.keys).union(remoteByName.keys).sorted() { + switch (localByName[name], remoteByName[name]) { + case let (.some(mine), .some(theirs)): + let chosen = winner(mine, theirs) + merged[name] = chosen + if chosen != theirs { toPush.append(chosen) } + if chosen != mine { toPull.append(chosen) } + case let (.some(mine), .none): + merged[name] = mine + toPush.append(mine) + case let (.none, .some(theirs)): + merged[name] = theirs + toPull.append(theirs) + case (.none, .none): + continue + } + } + + return MergeResult( + merged: merged.values.sorted { $0.recordName < $1.recordName }, + toPush: toPush.sorted { $0.recordName < $1.recordName }, + toPull: toPull.sorted { $0.recordName < $1.recordName } + ) + } + + /// Picks between two versions of the same record. + /// + /// Append-only kinds keep whichever version has content, so a session cannot be + /// erased by a device whose clock is wrong or which never saw it. Everything else + /// is last-writer-wins, and an exact timestamp tie is broken on payload bytes so + /// two devices merging the same pair always reach the same answer rather than + /// disagreeing forever. + private static func winner(_ left: SyncRecord, _ right: SyncRecord) -> SyncRecord { + if left == right { return left } + if left.kind.isAppendOnly { + if left.isDeleted != right.isDeleted { return left.isDeleted ? right : left } + } + if left.modifiedAt != right.modifiedAt { + return left.modifiedAt > right.modifiedAt ? left : right + } + let leftBytes = left.payload ?? Data() + let rightBytes = right.payload ?? Data() + if leftBytes.count != rightBytes.count { + return leftBytes.count > rightBytes.count ? left : right + } + return leftBytes.lexicographicallyPrecedes(rightBytes) ? right : left + } + + // MARK: - Records to document + + /// Rebuilds a document from merged records, keeping the parts of local state + /// that are not synced: the active session, and the bundled templates that ship + /// with the app rather than travelling between devices. + public static func document( + from records: [SyncRecord], + applyingTo local: SetlineDocument + ) throws -> SetlineDocument { + let decoder = makeDecoder() + var templates = local.templates.filter(\.isBundled) + var history: [WorkoutSession] = [] + var goals: [ExerciseGoal] = [] + var programme = local.programme + + for record in records.sorted(by: { $0.recordName < $1.recordName }) { + guard let payload = record.payload else { continue } + switch record.kind { + case .template: + templates.append(try decoder.decode(WorkoutTemplate.self, from: payload)) + case .session: + history.append(try decoder.decode(WorkoutSession.self, from: payload)) + case .goal: + goals.append(try decoder.decode(ExerciseGoal.self, from: payload)) + case .programme: + programme = try decoder.decode(ProgrammeSelection.self, from: payload) + } + } + + var merged = local + merged.templates = templates + // Newest first, matching how history is presented everywhere else. + merged.history = history.sorted { $0.startedAt > $1.startedAt } + merged.goals = goals.sorted { $0.createdAt < $1.createdAt } + merged.programme = programme + return merged + } + + // MARK: - Helpers + + static func parse(_ recordName: String) -> (SyncRecordKind, UUID)? { + guard let separator = recordName.firstIndex(of: "-") else { return nil } + let rawKind = String(recordName[recordName.startIndex.. JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(date.timeIntervalSinceReferenceDate) + } + // Sorted keys keep the payload byte-identical for identical values, which is + // what lets fingerprinting detect a real edit rather than a re-encode. + encoder.outputFormatting = [.sortedKeys] + return encoder + } + + static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let seconds = try decoder.singleValueContainer().decode(Double.self) + return Date(timeIntervalSinceReferenceDate: seconds) + } + return decoder + } +} diff --git a/ios/Sources/SetlineCore/Sync/SyncRecord.swift b/ios/Sources/SetlineCore/Sync/SyncRecord.swift new file mode 100644 index 0000000..fc3df79 --- /dev/null +++ b/ios/Sources/SetlineCore/Sync/SyncRecord.swift @@ -0,0 +1,103 @@ +import Foundation + +/// One synchronisable unit of training. +/// +/// Setline syncs per record rather than as one document. A whole-document copy +/// forces a person to choose between two versions of their training whenever two +/// devices both wrote, which is a decision no one can make correctly mid-workout. +/// Per record, the common cases resolve themselves: two phones that recorded +/// different sessions simply end up with both. +/// +/// The payload is the entity encoded exactly as the local document encodes it, so +/// there is one serialisation format rather than a second, drifting one. +public struct SyncRecord: Equatable, Sendable { + public var kind: SyncRecordKind + /// Identity of the entity, or `SyncRecordKind.singletonID` for the one-per-account + /// records such as the programme selection. + public var entityID: UUID + /// When this version was written. Comparable across devices only as well as + /// their clocks are, which is why history never resolves by time. + public var modifiedAt: Date + /// Absent for a tombstone: the record exists to say the entity was deleted. + public var payload: Data? + + public init(kind: SyncRecordKind, entityID: UUID, modifiedAt: Date, payload: Data?) { + self.kind = kind + self.entityID = entityID + self.modifiedAt = modifiedAt + self.payload = payload + } + + public var isDeleted: Bool { payload == nil } + + /// Stable, collision-free name for the record in its zone. + public var recordName: String { Self.recordName(kind: kind, entityID: entityID) } + + public static func recordName(kind: SyncRecordKind, entityID: UUID) -> String { + "\(kind.rawValue)-\(entityID.uuidString)" + } +} + +public enum SyncRecordKind: String, Codable, Sendable, CaseIterable { + case template + case session + case goal + case programme + + /// History is a log of things that happened. A completed session is never + /// edited and never deleted, so merging it is a union and time never decides + /// anything — which also means a wrong device clock cannot lose a workout. + public var isAppendOnly: Bool { self == .session } + + /// The identity used by kinds that have exactly one record. + public static let singletonID = UUID(uuidString: "5E71C0DE-0000-0000-0000-00000000FFFF")! +} + +/// Remembers what each record looked like when it was last written, so a local +/// edit can be dated without every domain type carrying an `updatedAt` field. +/// +/// Adding `updatedAt` to `WorkoutTemplate` and `ExerciseGoal` would have put sync +/// bookkeeping inside the training model, where it would need maintaining by every +/// call site that mutates one and would be wrong the moment a call site forgot. +/// Fingerprinting the encoded payload cannot be forgotten. +public struct SyncLedger: Codable, Equatable, Sendable { + public struct Stamp: Codable, Equatable, Sendable { + public var fingerprint: String + public var modifiedAt: Date + + public init(fingerprint: String, modifiedAt: Date) { + self.fingerprint = fingerprint + self.modifiedAt = modifiedAt + } + } + + public var stamps: [String: Stamp] + + public init(stamps: [String: Stamp] = [:]) { + self.stamps = stamps + } + + /// Dates a record: unchanged payloads keep the timestamp they already had, so + /// re-reading a document does not make every record look freshly edited and + /// win every merge. + public mutating func stamp(_ record: SyncRecord, now: Date) -> Date { + let fingerprint = Self.fingerprint(of: record.payload) + if let existing = stamps[record.recordName], existing.fingerprint == fingerprint { + return existing.modifiedAt + } + stamps[record.recordName] = Stamp(fingerprint: fingerprint, modifiedAt: now) + return now + } + + static func fingerprint(of payload: Data?) -> String { + guard let payload else { return "deleted" } + // Not a cryptographic digest: this only has to change when the bytes do, + // and it must stay identical across OS versions, so no Hasher seeding. + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in payload { + hash ^= UInt64(byte) + hash = hash.multipliedReportingOverflow(by: 0x100_0000_01b3).partialValue + } + return String(hash, radix: 16) + } +} diff --git a/ios/Tests/SetlineCoreTests/SyncEngineTests.swift b/ios/Tests/SetlineCoreTests/SyncEngineTests.swift new file mode 100644 index 0000000..a5f5684 --- /dev/null +++ b/ios/Tests/SetlineCoreTests/SyncEngineTests.swift @@ -0,0 +1,252 @@ +import XCTest + +@testable import SetlineCore + +/// The merge is the only part of syncing that can silently destroy recorded +/// training, so these tests are about loss and determinism rather than plumbing. +final class SyncEngineTests: XCTestCase { + private let epoch = Date(timeIntervalSince1970: 1_784_505_600) + + private func record( + _ kind: SyncRecordKind, + _ id: UUID, + at offset: TimeInterval, + payload: String? = "a" + ) -> SyncRecord { + SyncRecord( + kind: kind, + entityID: id, + modifiedAt: epoch.addingTimeInterval(offset), + payload: payload.map { Data($0.utf8) } + ) + } + + // MARK: - History + + func testHistoryFromTwoDevicesUnionsRatherThanReplacing() { + let mine = record(.session, UUID(), at: 0) + let theirs = record(.session, UUID(), at: 10) + + let result = SyncEngine.merge(local: [mine], remote: [theirs]) + + XCTAssertEqual(result.merged.count, 2, "both workouts must survive") + XCTAssertEqual(result.toPush, [mine]) + XCTAssertEqual(result.toPull, [theirs]) + } + + func testACompletedSessionSurvivesATombstone() { + // Nothing in the app deletes a workout, so a session tombstone can only be + // corruption or a stale device. It must never win. + let id = UUID() + let real = record(.session, id, at: 0) + let deletion = record(.session, id, at: 999, payload: nil) + + XCTAssertEqual(SyncEngine.merge(local: [real], remote: [deletion]).merged, [real]) + XCTAssertEqual(SyncEngine.merge(local: [deletion], remote: [real]).merged, [real]) + } + + // MARK: - Last writer wins + + func testLaterEditWinsForTemplatesAndGoals() { + let id = UUID() + let older = record(.template, id, at: 0, payload: "old") + let newer = record(.template, id, at: 60, payload: "new") + + let result = SyncEngine.merge(local: [older], remote: [newer]) + + XCTAssertEqual(result.merged, [newer]) + XCTAssertEqual(result.toPull, [newer], "the local copy is behind and must be replaced") + XCTAssertTrue(result.toPush.isEmpty) + } + + func testADeleteWinsOverAnOlderEdit() { + let id = UUID() + let edit = record(.goal, id, at: 0, payload: "target") + let deletion = record(.goal, id, at: 60, payload: nil) + + XCTAssertEqual(SyncEngine.merge(local: [edit], remote: [deletion]).merged, [deletion]) + } + + func testAnEditAfterADeleteResurrectsTheEntity() { + // Re-creating a goal after deleting it elsewhere is a legitimate action. + let id = UUID() + let deletion = record(.goal, id, at: 0, payload: nil) + let edit = record(.goal, id, at: 60, payload: "target") + + XCTAssertEqual(SyncEngine.merge(local: [deletion], remote: [edit]).merged, [edit]) + } + + // MARK: - Determinism + + func testIdenticalTimestampsResolveTheSameWayOnBothDevices() { + let id = UUID() + let mine = record(.template, id, at: 0, payload: "aaa") + let theirs = record(.template, id, at: 0, payload: "bbb") + + let onMyPhone = SyncEngine.merge(local: [mine], remote: [theirs]).merged + let onTheirPhone = SyncEngine.merge(local: [theirs], remote: [mine]).merged + + XCTAssertEqual(onMyPhone, onTheirPhone, "a tie must not depend on which side you are") + } + + func testMergingAnAlreadyMergedResultChangesNothing() { + let templateID = UUID() + let local = [record(.session, UUID(), at: 0), record(.template, templateID, at: 5)] + let remote = [record(.session, UUID(), at: 10), record(.template, templateID, at: 20, payload: "b")] + + let first = SyncEngine.merge(local: local, remote: remote) + let second = SyncEngine.merge(local: first.merged, remote: first.merged) + + XCTAssertEqual(second.merged, first.merged) + XCTAssertTrue(second.isUpToDate, "a settled merge must not keep proposing work") + } + + func testAnEmptyRemoteIsTreatedAsAFirstSyncNotAsDeletion() { + let local = [record(.session, UUID(), at: 0), record(.goal, UUID(), at: 1)] + + let result = SyncEngine.merge(local: local, remote: []) + + XCTAssertEqual(result.merged.count, 2) + XCTAssertEqual(result.toPush.count, 2) + XCTAssertTrue(result.toPull.isEmpty) + } + + // MARK: - Ledger + + func testAnUnchangedRecordKeepsItsOriginalDate() throws { + var document = SetlineDocument.sample + document.goals = [ExerciseGoal(exerciseName: "Bench press", metric: .estimatedOneRepMax, targetValue: 90)] + var ledger = SyncLedger() + + let first = try SyncEngine.records(for: document, ledger: &ledger, now: epoch) + let second = try SyncEngine.records( + for: document, + ledger: &ledger, + now: epoch.addingTimeInterval(3600) + ) + + XCTAssertEqual( + first.map(\.modifiedAt), + second.map(\.modifiedAt), + "re-reading a document must not make every record look freshly edited" + ) + } + + func testAnEditedRecordTakesTheNewDate() throws { + var document = SetlineDocument.sample + let goal = ExerciseGoal(exerciseName: "Bench press", metric: .estimatedOneRepMax, targetValue: 90) + document.goals = [goal] + var ledger = SyncLedger() + _ = try SyncEngine.records(for: document, ledger: &ledger, now: epoch) + + var edited = goal + edited.targetValue = 95 + document.goals = [edited] + let later = epoch.addingTimeInterval(3600) + let records = try SyncEngine.records(for: document, ledger: &ledger, now: later) + + let goalRecord = try XCTUnwrap(records.first { $0.kind == .goal }) + XCTAssertEqual(goalRecord.modifiedAt, later) + } + + func testDeletingAGoalProducesATombstoneSoTheDeleteTravels() throws { + var document = SetlineDocument.sample + let goal = ExerciseGoal(exerciseName: "Bench press", metric: .estimatedOneRepMax, targetValue: 90) + document.goals = [goal] + var ledger = SyncLedger() + _ = try SyncEngine.records(for: document, ledger: &ledger, now: epoch) + + document.goals = [] + let later = epoch.addingTimeInterval(60) + _ = try SyncEngine.records(for: document, ledger: &ledger, now: later) + let tombstones = SyncEngine.tombstones(for: document, ledger: &ledger, now: later) + + XCTAssertEqual(tombstones.count, 1) + XCTAssertEqual(tombstones.first?.entityID, goal.id) + XCTAssertTrue(tombstones.first?.isDeleted == true) + } + + func testATombstoneIsNotReissuedOnEverySync() throws { + var document = SetlineDocument.sample + document.goals = [ExerciseGoal(exerciseName: "Bench press", metric: .estimatedOneRepMax, targetValue: 90)] + var ledger = SyncLedger() + _ = try SyncEngine.records(for: document, ledger: &ledger, now: epoch) + + document.goals = [] + let later = epoch.addingTimeInterval(60) + XCTAssertEqual(SyncEngine.tombstones(for: document, ledger: &ledger, now: later).count, 1) + XCTAssertTrue( + SyncEngine.tombstones(for: document, ledger: &ledger, now: later.addingTimeInterval(60)).isEmpty, + "a delete already recorded must not keep being re-announced" + ) + } + + // MARK: - Round trip + + func testADocumentSurvivesEncodingToRecordsAndBack() throws { + var document = SetlineDocument.demoWithEvidence + document.goals = [ExerciseGoal(exerciseName: "Bench press", metric: .estimatedOneRepMax, targetValue: 90)] + var ledger = SyncLedger() + + let records = try SyncEngine.records(for: document, ledger: &ledger, now: epoch) + let restored = try SyncEngine.document(from: records, applyingTo: document) + + XCTAssertEqual(restored.history.count, document.history.count) + XCTAssertEqual(Set(restored.history.map(\.id)), Set(document.history.map(\.id))) + XCTAssertEqual(restored.goals, document.goals) + XCTAssertEqual(restored.programme, document.programme) + XCTAssertEqual( + Set(restored.templates.map(\.id)), + Set(document.templates.map(\.id)), + "bundled templates come from the app, custom ones from records; both must be present" + ) + } + + func testTheActiveSessionIsNeverSynced() throws { + var document = SetlineDocument.sample + document.programme = .none + let template = try XCTUnwrap(document.templates.first) + try document.startWorkout(template: template) + XCTAssertNotNil(document.activeSession) + var ledger = SyncLedger() + + let records = try SyncEngine.records(for: document, ledger: &ledger, now: epoch) + let activeIDs = Set(records.filter { $0.kind == .session }.map(\.entityID)) + + XCTAssertFalse( + activeIDs.contains(try XCTUnwrap(document.activeSession?.id)), + "a workout in progress belongs to the phone running it" + ) + } + + func testBundledTemplatesAreNotSyncedAsRecords() throws { + var document = SetlineDocument.sample + var ledger = SyncLedger() + XCTAssertTrue(document.templates.contains { $0.isBundled }) + + let records = try SyncEngine.records(for: document, ledger: &ledger, now: epoch) + let syncedTemplateIDs = Set(records.filter { $0.kind == .template }.map(\.entityID)) + let bundledIDs = Set(document.templates.filter(\.isBundled).map(\.id)) + + XCTAssertTrue( + syncedTemplateIDs.isDisjoint(with: bundledIDs), + "shipping content should not travel through a person's iCloud account" + ) + document.templates = [] + _ = try SyncEngine.records(for: document, ledger: &ledger, now: epoch) + XCTAssertTrue( + SyncEngine.tombstones(for: document, ledger: &ledger, now: epoch).isEmpty, + "a bundled template that was never a record cannot become a tombstone" + ) + } + + func testRecordNamesRoundTripThroughParsing() { + let id = UUID() + for kind in SyncRecordKind.allCases { + let name = SyncRecord.recordName(kind: kind, entityID: id) + let parsed = SyncEngine.parse(name) + XCTAssertEqual(parsed?.0, kind) + XCTAssertEqual(parsed?.1, id) + } + } +}