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) + } + } +}