15 December 2024

“Now we’re all Forked!” | AppDecentral

"App Decentral" 
refers to the concept of building applications that are decentralized, meaning they operate on a distributed network without a single central authority, utilizing blockchain technology to store and manage data across multiple nodes instead of relying on a single server.
Drew McCormack
Bio: Decentral data systems enthusiast. Co-founder of agenda.com and founder of ensembles.io

A p p D e c e n t r a l

Swift development in a data decentral universe

Now we’re all Forked!

TLDR; I’m launching a new Swift framework called Forked for working with shared data, both on a single device, and across many.

A few years ago, I was knee-deep developing the collaboration feature of our app Agenda. Agenda is mostly local-first, so it was a challenge. 
Effectively, Agenda is a decentralized system, and the collaboration feature would allow anyone in a group to edit a shared note at any time — even when they were offline for days. When each copy of a shared note was transferred over to the devices of other members of the group, the result had to be consistent. It would be unacceptable for two people to end up with different versions.

I mentioned that Agenda is a local-first app. That means there is no central server with any understanding of the data model, taking care of conflicts — there is no central truth. Each Agenda client app has to take the data it gets from the cloud, make sense of it, and merge it in such a way that the result is the same as what other devices end up with, even if the data in question is days old.

What I realized back then is that this problem has already been solved very elegantly by a product that is extremely well-known and popular, and right under our noses. It’s called Git.> . .Today, I’m launching Forked, a new approach to working with shared data in Swift. And it has actually worked out better than I expected. I wasn’t even sure it would be possible to build, but with the new Swift macros, I was able to come up with a minimal API that seems to work great. I’m really looking forward to dog fooding it.

Let’s just finish up with a little code, so you can see how simple it turned out to be. Here’s a model from the Forkers sample app, which is basically a basic contacts app:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@ForkedModel
struct Forkers: Codable {
    @Merged(using: .arrayOfIdentifiableMerge)
    var forkers: [Forker] = []
}
 
@ForkedModel
struct Forker: Identifiable, Codable, Hashable {
    var id: UUID = .init()
    var firstName: String = ""
    var lastName: String = ""
    var company: String = ""
    var birthday: Date?
    var email: String = ""
    var category: ForkerCategory?
    var color: ForkerColor?
    @Merged var balance: Balance = .init()
    @Merged var notes: String = ""
    @Merged var tags: Set<String> = []
}

What I love the most about Forked models is that they are just simple value types. The @ForkedModel macro doesn’t change the properties at all, it just adds some code in an extension to support 3-way merging. So you can use this on any struct, and the result can do everything your original struct could do, from encoding to JSON, to jumping seamlessly between isolation domains in Swift 6.

The merging that @ForkedModel provides is pretty powerful. It does property-wise merging of structs, and if you attach the @Merged attribute, you can add your own custom merging logic, or use the advanced algorithms built in (like CRDTs).

To give an example, the notes property above is a String. With @Merged applied, it gets a hidden power — it can resolve conflicts in a more natural way. Rather than discarding one set of changes, or merging to give somewhat arbitrary results, it produces a result a person would likely expect. For example, if we begin with the text “pretty cool”, and change the text to “Pretty Cool” on one device, and to “pretty cool!!!” on another, the merged result result will be “Pretty Cool!!!”. Nuff said.

And this works within your app’s process, between processes (eg with sharing extensions), and even between devices via iCloud.

Also worth noting: Forked models work great with Swift 6 structured concurrency, helping to avoid race conditions. When there is a chance you might get a race condition (eg due to interleaving in an actor), you can setup a QuickFork — equivalent to an in-memory Git repo — and use branches (known as forks in Forked) to isolate each set of changes, merging later to get a valid result.

To finish off, consider this: With your model supporting 3-way merging, it knows how to merge itself. All it needs is a conflicting version, and a common ancestor, and Boom! So adding support for CloudKit to your app is next to trivial, and your model can remain completely unchanged. Here is the code that Forkers uses to setup CloudKit sync:

1
2
3
4
5
6
7
8
9
10
11
12
let forkedModel = try ForkedResource(repository: repo)
let cloudKitExchange = try .init(id: "Forkers",
    forkedResource: forkedModel)
 
// Listen for incoming changes from CloudKit
Task {
    for await change in forkedModel.changeStream
        where change.fork == .main &&
              change.mergingFork == .cloudKit {
        // Update UI...
    }
}

That’s all of it! We just added sync to our app in less than 10 lines of code. Decentralized systems can sometimes be astounding, and they also work great even when your use case is not technically decentralized! 

No comments:

Happy Holidays! From the Mesa Chamber of Commerce

  Click above to watch our holiday video! HOLIDAY HOURS In observance of the holidays, our office will be closed on Tuesday and Wednesday, D...