Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions ios/Setline.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 */; };
Expand Down Expand Up @@ -96,6 +99,7 @@
40CADB4F69054A14B366A16D /* Setline.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Setline.entitlements; sourceTree = "<group>"; };
4833E444B40BC982AC57AACC /* TwelveWeekProgramme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwelveWeekProgramme.swift; sourceTree = "<group>"; };
594DC48A6CD7B68259549A12 /* Progression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Progression.swift; sourceTree = "<group>"; };
5D57699DA4317A7229DA6B21 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
5D9600ED385C5C29CB0702D3 /* ExercisesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesView.swift; sourceTree = "<group>"; };
602F0E123ED63C93EDDBA179 /* ExerciseCatalogue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExerciseCatalogue.swift; sourceTree = "<group>"; };
608F81A574360691A58B8581 /* SetlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineApp.swift; sourceTree = "<group>"; };
Expand All @@ -104,13 +108,15 @@
6E6AEE25CFD26FE63620A033 /* TodayResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayResolution.swift; sourceTree = "<group>"; };
818A8EDA05B5D0B4E9DE47B3 /* WorkoutPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutPlayerView.swift; sourceTree = "<group>"; };
824EFFB1023C857CE4F1FE1C /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
9405EFEDE8A70CC15F9E0309 /* SyncRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncRecord.swift; sourceTree = "<group>"; };
B0859DE334CC4D97FFE0DFDA /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = "<group>"; };
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; };
B839442BFCCB000A563CD704 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
B888AC3A36F08B6334AE20CA /* Targets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Targets.swift; sourceTree = "<group>"; };
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 = "<group>"; };
CC052D7878745A06AC98A7E9 /* SyncEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngineTests.swift; sourceTree = "<group>"; };
CE77BBBECF6D7ED1F7E8047E /* SetEntryParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetEntryParser.swift; sourceTree = "<group>"; };
DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryViews.swift; sourceTree = "<group>"; };
F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
Expand Down Expand Up @@ -190,11 +196,21 @@
B888AC3A36F08B6334AE20CA /* Targets.swift */,
6E6AEE25CFD26FE63620A033 /* TodayResolution.swift */,
4833E444B40BC982AC57AACC /* TwelveWeekProgramme.swift */,
7F11BB8EB61A5F8FAC4C3B95 /* Sync */,
);
name = SetlineCore;
path = Sources/SetlineCore;
sourceTree = "<group>";
};
7F11BB8EB61A5F8FAC4C3B95 /* Sync */ = {
isa = PBXGroup;
children = (
5D57699DA4317A7229DA6B21 /* SyncEngine.swift */,
9405EFEDE8A70CC15F9E0309 /* SyncRecord.swift */,
);
path = Sync;
sourceTree = "<group>";
};
C93C5A86494CB1CE017F1623 /* Products */ = {
isa = PBXGroup;
children = (
Expand All @@ -219,6 +235,7 @@
isa = PBXGroup;
children = (
1211B40E359F4BBD82A557A9 /* SetlineCoreTests.swift */,
CC052D7878745A06AC98A7E9 /* SyncEngineTests.swift */,
);
name = SetlineCoreTests;
path = Tests/SetlineCoreTests;
Expand Down Expand Up @@ -396,6 +413,7 @@
buildActionMask = 2147483647;
files = (
677B5B2EB644215DBE6CE4D0 /* SetlineCoreTests.swift in Sources */,
DEE8DD0CE56008BF90B164DB /* SyncEngineTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -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 */,
Expand Down
259 changes: 259 additions & 0 deletions ios/Sources/SetlineCore/Sync/SyncEngine.swift
Original file line number Diff line number Diff line change
@@ -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<String> = []
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..<separator])
let rawID = String(recordName[recordName.index(after: separator)...])
guard let kind = SyncRecordKind(rawValue: rawKind), let id = UUID(uuidString: rawID) else {
return nil
}
return (kind, id)
}

/// Sync payloads are internal, so they are encoded for exactness rather than
/// for reading.
///
/// The export format uses ISO 8601, which truncates to whole seconds. That is
/// right for a file a person might open, but wrong here: a truncated date does
/// not decode back to the value it came from, so a record would look edited
/// every time it made the trip and two edits inside one second would tie on
/// timestamp and fall through to an arbitrary tie-break.
static func makeEncoder() -> 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
}
}
Loading
Loading