Skip to content

Commit

Permalink
Merge pull request #20 from drewmccormack/feature/salvaging
Browse files Browse the repository at this point in the history
Introduce “salvaging” (2-way merge) for bootstrapping
  • Loading branch information
drewmccormack authored Jan 10, 2025
2 parents af52c37 + 20926a3 commit 437b702
Show file tree
Hide file tree
Showing 457 changed files with 548 additions and 413 deletions.
15 changes: 15 additions & 0 deletions Samples/Forkers/Forkers/Models/Forker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,18 @@ struct Forker: Identifiable, Codable, Hashable {
@Merged var notes: String = ""
@Merged var tags: Set<String> = []
}

extension Forkers {

func salvaging(from other: Forkers) throws -> Forkers {
// When two devices have unrelated histories, they can't be
// 3-way merged. Instead, we will start with the dominant
// forker values (eg from the cloud), and copy in any forkers unique
// to the subordinate data (eg local)
var new = self
let ids = Set(self.forkers.map(\.id))
new.forkers += other.forkers.filter { !ids.contains($0.id) }
return new
}

}
5 changes: 4 additions & 1 deletion Sources/Forked/ForkedResource+Merging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,13 @@ public extension ForkedResource where RepositoryType.Resource: Mergeable {
switch (commits.dominant.content, commits.subordinate.content, ancestorCommit.content) {
case (.none, .none, _):
return .none
case (.resource, .none, _), (.resource, .resource, .none):
case (.resource, .none, _):
return commits.dominant.content
case (.none, .resource, _):
return commits.subordinate.content
case (.resource(let r1), .resource(let r2), .none):
let resource = try r1.salvaging(from: r2)
return .resource(resource)
case (.resource(let r1), .resource(let r2), .resource(let ra)):
let resource = try r1.merged(withSubordinate: r2, commonAncestor: ra)
return .resource(resource)
Expand Down
43 changes: 36 additions & 7 deletions Sources/Forked/Mergeable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,35 @@ public protocol Mergeable: Equatable {
/// be considered the `dominant` fork, and `other` subordinate. If you must choose, choose `self`.
func merged(withSubordinate other: Self, commonAncestor: Self) throws -> Self

/// In general, 3-way merges are used in Forked. But when bootstrapping,
/// there can be times when no common ancestor exists. Effectively we have
/// to merge together unrelated values. For example, if you install an app on
/// two offline devices, insert some data on each, and then take them online to
/// sync. In this scenario, there is no common ancestor,
/// but it would be nice to keep the data entered on each device.
/// An even trickier case arises if two devices are fully synced up, but then the
/// cloud data is reset. Effectively, the two data sets are now unrelated, and if you
/// start them syncing again, the history relating them is lost, and there is no common
/// ancestor. You can choose one or the other, but just blindly merging the two will
/// lead to duplications (how often have we seen that in apps like Contacts?)
///
/// That's a lot of introduction, but it sets up this function. This function is effectively
/// a 2-way merge. By default, it just returns `self`, which is considered the dominant
/// copy of the data. But if you need special handling to bootstrap, you can "salvage"
/// data from `other` and merge it in. It is even possible to setup a 3-way merge
/// where you construct an initial value and use that as the common ancestor, but
/// this may not work well for all properties. Often a combination of approaches is best
/// for salvaging, eg, starting with a 3-way merge against the initial value, and then
/// copying in properties from `self` where this 3-way merge doesn't do what you
/// want.
func salvaging(from other: Self) throws -> Self

}

public extension Mergeable {

func salvaging(from other: Self) throws -> Self { self }

}

extension Optional: Mergeable where Wrapped: Mergeable {
Expand All @@ -22,16 +51,16 @@ extension Optional: Mergeable where Wrapped: Mergeable {
} else {
// Conflicting changes
switch (self, other, commonAncestor) {
case (.none, .none, _):
return .none
case (.some(let s), .none, _):
case (nil, nil, _):
return nil
case (let s?, nil, _):
return s
case (.none, .some(let o), _):
case (nil, let o?, _):
return o
case (.some(let s), .some(let o), .some(let c)):
case (let s?, let o?, nil):
return try s.salvaging(from: o)
case (let s?, let o?, let c?):
return try s.merged(withSubordinate: o, commonAncestor: c)
case (.some(let s), .some, .none):
return s
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/ForkedModelMacros/ForkedModelMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ public struct ForkedModelMacro: ExtensionMacro, MemberMacro {
let mergeableExtension = try generateMergeableExtension(for: type, structDecl: structDecl, defaultMergeVars: defaultMergeVars, mergePropertyVars: mergePropertyVars, backedPropertyVars: backedPropertyVars)

// If version is provided, also generate VersionedModel extension
if let version = extractVersion(from: node) {
if extractVersion(from: node) != nil {
let versionedModelExtension = try ExtensionDeclSyntax(
"""
extension \(type.trimmed): Forked.VersionedModel {}
Expand Down
44 changes: 44 additions & 0 deletions Tests/ForkedTests/ForkedMergable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,48 @@ struct MergingMergeableSuite {
#expect(try resource.resource(of: .main) == Pair(a: 2, b: 3))
}

@Test func salvagingWhenBootstrapping() async throws {
struct SalvagablePair: Equatable, Mergeable {
var a: Int
var b: Int

func merged(withSubordinate other: Self, commonAncestor: Self) throws -> Self {
var result = self
if self.a == commonAncestor.a && other.a != commonAncestor.a {
result.a = other.a
}
if self.b == commonAncestor.b && other.b != commonAncestor.b {
result.b = other.b
}
return result
}

func salvaging(from other: SalvagablePair) throws -> SalvagablePair {
other
}
}

do {
let p1 = Pair(a: 1, b: 2)
try resource.update(.main, with: p1)
let p2 = Pair(a: 2, b: 1)
try resource.update(fork, with: p2)
try resource.mergeIntoMain(from: fork)
let m1 = try resource.value(in: .main)!
#expect(m1 == p2) // fork is dominant because updated last
}

do {
let resource = QuickFork<SalvagablePair>()
try resource.create(fork)

let p1 = SalvagablePair(a: 1, b: 2)
try resource.update(.main, with: p1)
let p2 = SalvagablePair(a: 2, b: 1)
try resource.update(fork, with: p2)
try resource.mergeIntoMain(from: fork)
let m1 = try resource.value(in: .main)!
#expect(m1 == p1) // "salvaged" chooses other
}
}
}
2 changes: 1 addition & 1 deletion docs/Forked/assets.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"downloads":[],"images":[{"variants":[{"url":"\/images\/Forked\/ForkedHub.png","traits":["1x","light"]}],"identifier":"ForkedHub","type":"image","alt":"The hub-and-spoke architecture of Forked."}],"videos":[]}
{"images":[{"variants":[{"url":"\/images\/Forked\/ForkedHub.png","traits":["1x","light"]}],"identifier":"ForkedHub","alt":"The hub-and-spoke architecture of Forked.","type":"image"}],"downloads":[],"videos":[]}
2 changes: 1 addition & 1 deletion docs/Forked/data/documentation/forked.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Loading

0 comments on commit 437b702

Please sign in to comment.