Author: Drew McCormack (@drewmccormack)
Ever wish it was as easy to move your app's data around as it is to push and pull your source code with Git?
LLVS brings the same model to app data. Every save creates a version. Versions branch, merge, and sync between devices, just like commits in a Git repository. Your app gets full version history, conflict resolution, and multi-device sync without writing any networking or diffing code.
A user edits a note on their phone during a flight. Meanwhile, a share extension updates the same note on their iPad. Later, their Watch app writes a quick addition. When the phone comes back online, three copies of the data have diverged independently.
LLVS handles this the way Git handles divergent branches: it tracks the full ancestry of every change, finds the common ancestor when versions diverge, and merges them back together through a conflict resolver you control.
- Version history. Every save is a version. Branch, merge, diff, or read the store as it was at any point in time.
- Three-way merge. When versions diverge, LLVS finds their common ancestor and diffs both sides. You provide a
MergeArbiterto resolve conflicts however you like, or use a built-in one. - Sync without networking code. Push and pull versions via CloudKit, WebDAV, Google Drive, OneDrive, Box, pCloud, a shared directory, a peer-to-peer link, or your own exchange.
- Typed models, if you want them. The
LLVSModellibrary storesCodablestructs and merges them property by property with the@MergeableModelmacro. - Multi-process safe. Share a file-based store between your main app, extensions, and widgets using an app group container.
- Pluggable storage. File-based storage by default, SQLite via
LLVSSQLite, or bring your own backend. - Encryption-friendly. LLVS stores opaque
Datablobs. Encrypt them however you want; the framework never inspects your data.
Everything asynchronous uses async/await. There are no completion handlers and no Combine.
This walks through a minimal app that syncs a single shared message via CloudKit. The full code is in Samples/TheMessage.
StoreCoordinator is the simplest entry point. It wraps a Store, tracks the current version for your UI, and orchestrates sync and merging.
import LLVS
import LLVSCloudKit
import CloudKit
let coordinator = try StoreCoordinator()
let container = CKContainer(identifier: "iCloud.com.mycompany.themessage")
coordinator.exchange = CloudKitExchange(
with: coordinator.store,
storeIdentifier: "MainStore",
cloudDatabaseDescription: .publicDatabase(container)
)StoreCoordinator() puts the store in Application Support. Use StoreCoordinator(withStoreDirectoryAt:cacheDirectoryAt:) if you want to choose the location, for example an app group container.
let messageId = Value.ID("MESSAGE")
func post(message: String) throws {
let value = Value(id: messageId, data: message.data(using: .utf8)!)
try coordinator.save(updating: [value])
sync()
}
func fetchMessage() -> String? {
guard let value = try? coordinator.value(id: messageId) else { return nil }
return String(data: value.data, encoding: .utf8)
}
func sync() {
Task {
try? await coordinator.exchange()
_ = try? coordinator.merge()
}
}exchange() sends and receives versions with the cloud. It is a bit like a two-way git fetch: it moves data, but does not touch your current version. merge() then reconciles any concurrent changes and moves the current version forward. That's the entire sync implementation.
currentVersionUpdates is an AsyncStream<Version.ID> that yields whenever the current version changes, whether from a local save or a merge.
Task {
for await _ in coordinator.currentVersionUpdates {
self.message = fetchMessage() ?? ""
}
}This example is deliberately minimal. What happens when data diverges across devices is covered below.
LLVS is installed with the Swift Package Manager. It requires Swift tools 6.1, and macOS 15, iOS 18, or watchOS 11.
dependencies: [
.package(url: "https://github.com/mentalfaculty/LLVS.git", from: "0.11.0")
]In Xcode, choose File > Add Package Dependencies..., enter the repository URL, and select the libraries your target needs.
| Library | What it is |
|---|---|
LLVS |
The core: Store, StoreCoordinator, merging, file storage, and the in-core exchanges |
LLVSSQLite |
SQLite storage backend |
LLVSModel |
Typed models, the @MergeableModel macro, MergeableArbiter |
LLVSCloudKit |
CloudKit exchange |
LLVSWebDAV, LLVSGoogleDrive, LLVSOneDrive |
Cloud file systems for use with CloudFileSystemExchange |
LLVSBox, LLVSPCloud |
Exchanges that wrap the Box and pCloud SDKs (need a trait, see below) |
The core LLVS library depends on ZIPFoundation, which is used for snapshots. LLVSModel depends on swift-syntax for its macro.
LLVSBox and LLVSPCloud wrap vendor SDKs, and I don't want those SDKs downloaded into apps that never use them. They are gated behind package traits, so you have to opt in:
.package(url: "https://github.com/mentalfaculty/LLVS.git", from: "0.11.0", traits: ["Box"])The traits are Box and PCloud. Without the trait, the module builds but is empty, and the SDK is not fetched.
The core framework deals in raw Data. If your model is made of Codable structs, LLVSModel saves you the boilerplate, and gives you a much better merge. This is how the LoCo sample stores its contacts.
import LLVS
import LLVSModel
@MergeableModel
struct Contact: StorableModel, Equatable, Identifiable, Codable {
static let modelTypeIdentifier = "Contact"
var id: UUID = .init()
var firstName: String = ""
var lastName: String = ""
var city: String = ""
var avatarJPEGData: Data?
}StorableModel is Codable plus a stable modelTypeIdentifier. Each instance is stored as JSON in one value, with the identifier "<instanceIdentifier>/Contact". The instance identifier leads so that instances of one type spread across the value map rather than crowding into a single node; see Upgrading to 0.12 if you have a store written by an earlier version.
@MergeableModel generates a Mergeable conformance that does a three-way merge of each stored property. If one device changes firstName and another changes city, both edits survive. Properties that are themselves Mergeable (including optionals of Mergeable types) are merged recursively; plain Equatable properties are compared against the common ancestor. Nested types only need @MergeableModel; StorableModel is just for the top-level types you save.
To use that merge, register your types with a MergeableArbiter:
let coordinator = try StoreCoordinator()
let arbiter = MergeableArbiter()
arbiter.register(Contact.self)
coordinator.mergeArbiter = arbiterValues of unregistered types fall through to fallbackArbiter, which defaults to MostRecentChangeFavoringArbiter.
Saving and fetching are typed extensions on StoreCoordinator:
let contact = Contact(firstName: "Ada")
try coordinator.save(contact, instanceIdentifier: contact.id.uuidString)
let one = try coordinator.fetchModel(Contact.self, instanceIdentifier: contact.id.uuidString)
let all = try coordinator.fetchAllModels(Contact.self)
try coordinator.removeModel(Contact.self, instanceIdentifier: contact.id.uuidString)LLVS answers "what is this value at this version". It does not answer "which contacts were edited this week", because every value is an opaque blob keyed by ID.
LLVSProjection fills that gap without making LLVS query-aware. It keeps a SQLite table of current values, updated from version diffs, and you query that. The store stays the truth; the table is an index over it.
import LLVSProjection
let contacts = ProjectedType(
typeIdentifier: Contact.modelTypeIdentifier,
tableName: "contacts",
columns: [
ProjectedColumn(name: "name", declaration: "TEXT"),
ProjectedColumn(name: "age", declaration: "INTEGER"),
],
extract: { value in
let contact = try JSONDecoder().decode(Contact.self, from: value.data)
return ["name": .text(contact.name), "age": .integer(Int64(contact.age))]
}
)
let follower = try ProjectionFollower(
databaseURL: databaseURL,
coordinator: coordinator,
types: [contacts],
schemaVersion: 1)Project after a save, then query:
try coordinator.save(inserting: [value])
await follower.projectCurrentVersion()
let names = try await follower.query { database in
var names: [String] = []
try database.forEach(matchingQuery: "SELECT name FROM contacts WHERE age > 30 ORDER BY name") { row in
if let name: String = row.value(inColumnAtIndex: 0) { names.append(name) }
}
return names
}followUpdates(onResult:) drives the same thing from the coordinator's version stream, for an app that would rather not await each save.
Four things are worth knowing before building on it.
The projection is an index, not a copy. Declare only the columns you query or sort on, and read the whole object from the store once a query has named the IDs. That keeps writes cheap and the database small.
Changing your columns is a rebuild, not a migration. Raise schemaVersion, and the next pass throws the tables away and re-projects everything from the store. There is no migration to write, because the truth never lived in SQLite. Losing the database entirely is the same event, and equally survivable.
A value that will not decode is skipped, not fatal. If extract throws, because another device wrote a model this build cannot read, that value is left out and its ID comes back in ProjectionResult.unreadableIds. One unreadable value never blocks the rest, and nothing disappears without your app being told. The ID list is capped, since a version-skew event can make every value unreadable at once; unreadableCount always gives the true number.
A pass is all or nothing. Rows and the version marker are written in one transaction, so a crash part-way leaves the projection on its previous version rather than half-updated. Running again repeats the same work.
ProjectionFollower is an actor, and it owns both the projector and the SQLite connection. Neither is thread-safe, so neither is reachable from outside it — which is why the database is opened from a URL rather than handed in, and why queries go through query.
That matters most for the SQLite connection, which is not thread-safe and would crash if used from two threads at once. Keeping it inside the actor is what makes query and a projection pass safe against each other.
The section above is one-way: LLVS is the truth, the SQLite an index over it, and you write through StoreCoordinator. An owned table turns that around. You write ordinary SQL, and those writes become versions that sync and merge.
Think of it as a checkout. The SQLite holds the state at some version, and writing to it makes a new one.
@MergeableModel
struct Note: StorableModel, Codable, Equatable {
static let modelTypeIdentifier = "Note"
var title: String = ""
var body: String = ""
var updatedAt: Date = .now
var tags: [String] = []
}
let table = OwnedTable(
typeIdentifier: Note.modelTypeIdentifier,
tableName: "notes",
schema: Note.sqliteSchema)
for statement in table.createStatements() {
try database.execute(statement: statement)
}The table is ordinary. Index it however you like:
try database.execute(statement: "CREATE INDEX notes_updated ON notes(updated_at)")Write ordinary SQL. Triggers record what changed, and a drain turns it into a version:
try database.execute(statement: "UPDATE notes SET title = ? WHERE llvs_id = ?",
withBindingsList: [["New title", noteId]])
let result = try table.drain(in: database, store: store)The table records which version it is a working copy of, so you do not have to carry that across a launch. table.currentVersion(in: database) reads it back, and basedOn: overrides it when you deliberately want to build on something else.
Read rows back as models:
let rows = try table.fetch(Note.self, in: database, where: "updated_at > ?", bindings: [cutoff])
for row in rows { print(row.model.title, row.id) }And apply what arrives from elsewhere:
let changes = try store.valueChanges(updatingFrom: oldVersion, to: newVersion)
try table.apply(changes, in: database, atVersion: newVersion)Passing atVersion records what those changes brought the table to, in the same transaction as the rows, so the next drain continues from what arrived rather than from what this device last wrote.
@MergeableModel generates the schema. A property typed String, Int, Double, Bool, Date, UUID or Data — or an optional of one — becomes a real column you can index. Anything else becomes a TEXT column holding JSON, still queryable through json_extract and json_each.
Only the awkward property becomes JSON. A model with an array of tags still gets a plain indexed TEXT column for its title.
A Date column holds Unix seconds, so WHERE updated_at > strftime('%s', 'now', '-7 days') means what it looks like. (Codable encodes dates as seconds since 2001; the conversion happens at the boundary so you never see it.)
A property whose type is inferred rather than written down, such as var count = 0, gets no column, because a macro sees only syntax and guessing would be wrong. Annotate it — var count: Int = 0 — and it gets one. Anything skipped is listed in sqliteSchema.propertiesWithoutColumns.
Column names are snake_cased, and one that would collide with a SQLite keyword takes a trailing underscore, so var when: Date becomes when_.
The merge happens in LLVS. SQLite never sees a conflict.
A row change becomes a value, and values merge the way they always have. Because a column is a property, MergeableArbiter merges them independently: if you edit a note's title on your phone while a share extension edits its body, both survive. Register your types and that is what you get.
let arbiter = MergeableArbiter()
arbiter.register(Note.self)
coordinator.mergeArbiter = arbiterTwo devices changing the same column is a real conflict, and it goes to your MergeArbiter, which is what it is for.
A delete racing an edit brings the row back carrying the edit, because the default arbiter favours the more recent change. A row that reappears is visible and fixable; an edit that silently vanished is neither. Change it in your arbiter if your app wants the opposite.
Drive one table from one place. This is the one that loses data if you ignore it. Capture is suppressed while a version from elsewhere is applied, and that suppression is global to the table, so a write from your app during an apply is swallowed: the row changes, nothing is captured, and the edit never syncs. The row still shows what the user typed, so nothing looks wrong until it fails to arrive on their other device. apply takes the write lock first, which makes another connection wait, but nothing can protect a second thread sharing yours. Put the table behind an actor or a serial queue — SQLiteDatabase asks the same of you already.
Writes are SQL, reads are typed. That is deliberate. UPDATE notes SET title = ? says only the title changed, and that is what lets your edit and another device's merge. A save(note) writing every column would claim they all changed and throw that away. A typed write is possible, but it has to diff against the stored row first.
A rebuild drains first. Rebuilding discards the table, so anything written but not yet drained must reach LLVS before that happens. Register the table with registerOwnedTable and call drainOwnedTables before rebuilding; a failed drain stops the whole thing rather than losing the work.
Your own transactions need nothing special. Triggers fire inside your transaction, so a committed one yields exactly one version and a rolled-back one yields nothing at all.
StoreCoordinator is convenient for common cases, but Store gives you direct access to the version graph: branching, merging, diffing, and time travel.
let rootDir = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp")!
.appendingPathComponent("MyStore")
let store = try Store(rootDirectoryURL: rootDir)Using an app group container lets your main app, extensions, and widgets share the same store. Each process keeps its history in memory, so call try coordinator.store.reloadHistory() before merging, to pick up versions written by the other processes.
Every write creates a new version.
let value = Value(idString: "ABCDEF", data: "Hello".data(using: .utf8)!)
let firstVersion = try store.makeVersion(basedOnPredecessor: nil, inserting: [value])Passing nil for the predecessor creates an initial version, like Git's first commit. Subsequent changes build on a predecessor, and inserts, updates, and removes can be combined in a single call:
let secondVersion = try store.makeVersion(
basedOnPredecessor: firstVersion.id,
inserting: [newValue],
updating: [changedValue],
removing: [obsoleteValueId]
)Versions are store-wide: once a value is added, it persists in all subsequent versions until explicitly updated or removed. You can retrieve any value at any version.
let value = try store.value(idString: "ABCDEF", at: secondVersion.id)You can also ask what changed, with store.valueChanges(madeInVersionIdentifiedBy:) and store.valueChanges(madeBetween:and:).
Data is stored once, under the version that wrote it. Later versions just refer to it.
When concurrent changes happen (edits on two devices between syncs, or writes from both your app and its share extension) the version history naturally diverges. This isn't an error; it's the normal state of decentralized data. The divergence gets reconciled through merging.
Each Version can have up to two predecessors (one for linear history, two for a merge) and any number of successors. A head is a version with no successors, the tip of a line of history. When multiple heads exist, they generally need to be merged.
store.queryHistory { history in
let heads = history.headIdentifiers
// ...
}
let latest: Version? = store.mostRecentHeadAlways go through queryHistory to touch the History; it serializes access.
Divergence from syncing is anonymous, but you can also make a named Branch for background work, such as a long import. Pass it when saving with the coordinator (save(updating:in:)), and it is recorded in the version's metadata. Named branches are left alone by merge() by default. You bring them in when you are ready, using the headSelection argument.
let importBranch = Branch(rawValue: "import")
try coordinator.save(inserting: importedValues, in: importBranch)
// ...later
try coordinator.merge(headSelection: .allUnbranchedAndSpecificBranches([importBranch]))When two versions have diverged, LLVS performs a three-way merge: it finds the greatest common ancestor, diffs each side against it, and hands the results to a MergeArbiter. The arbiter decides how to resolve every conflict.
let arbiter = MostRecentChangeFavoringArbiter()
let merged = try store.merge(version: headA, with: headB, resolvingWith: arbiter)If one version is an ancestor of the other, LLVS fast-forwards without creating a new version, just like Git. If the two versions have no common ancestor at all, it falls back to a two-way merge.
store.mergeHeads(into:resolvingWith:) merges all the other heads into a version in one call, and StoreCoordinator.merge() does the same for the coordinator's current version, using its mergeArbiter. Heads are merged in an order that depends only on the versions themselves, so every device does it the same way.
There are three built-in arbiters:
MostRecentChangeFavoringArbiterresolves each conflict individually, keeping whichever change is newer. An update always beats a removal. This is the coordinator's default.MostRecentBranchFavoringArbiterresolves all conflicts in favor of whichever of the two versions has the newer timestamp.MergeableArbiter(inLLVSModel) merges registered model types property by property, as described above.
For full control, implement the protocol yourself.
public protocol MergeArbiter {
func changes(toResolve merge: Merge, in store: Store) throws -> [Value.Change]
}The Merge gives you the two versions, the commonAncestor (if any), and forksByValueIdentifier, a dictionary of Value.Fork describing what happened to each value:
.inserted,.updated,.removed(each carrying the branch,.firstor.second) and.twiceRemovedare not conflicts. LLVS handles them for you..twiceInserted,.twiceUpdated, and.removedAndUpdated(removedOn:)are conflicts.fork.isConflictingtells you which is which.
Your arbiter must return a Value.Change for every conflicting fork. Use .preserve(reference) to keep an existing value from one side, .preserveRemoval(id) to keep a removal, or .update(value) to write something new. This is where you encode your app's domain logic. Here is an arbiter where the longer text wins:
final class LongestTextArbiter: MergeArbiter {
func changes(toResolve merge: Merge, in store: Store) throws -> [Value.Change] {
let v = merge.versions
var changes: [Value.Change] = []
for (valueId, fork) in merge.forksByValueIdentifier {
switch fork {
case .twiceInserted, .twiceUpdated:
let first = try store.value(id: valueId, at: v.first.id)!
let second = try store.value(id: valueId, at: v.second.id)!
let winner = first.data.count >= second.data.count ? first : second
changes.append(.preserve(winner.reference!))
case let .removedAndUpdated(removedOn):
let updated = removedOn == .first ? v.second : v.first
let value = try store.value(id: valueId, at: updated.id)!
changes.append(.preserve(value.reference!))
case .inserted, .updated, .removed, .twiceRemoved:
break
}
}
return changes
}
}If you are not using LLVSModel, how you map your model onto values is up to you, but the granularity matters:
| Approach | Merging | Performance | Disk use |
|---|---|---|---|
| One property per Value | Best (per-property conflict resolution) | Slow (many small reads) | Many small files |
| One entity per Value | Good (per-entity conflict resolution) | Moderate | Moderate |
| Entire model in one Value | Poor (must merge everything manually) | Fast (single read) | Large per-version files |
One entity per Value is a good default, and it is what LLVSModel does. With @MergeableModel you get per-property merging on top, without paying for per-property storage.
The Storage protocol creates Zone instances, and a Zone is just a raw read/write interface for blobs. Two are included:
FileStorage(the default) keeps files on disk under the store's root directory, in 2-character prefix subdirectories to keep the file system happy. It is safe to use from multiple processes.SQLiteStorage(inLLVSSQLite) keeps the same data in SQLite databases.
import LLVSSQLite
let store = try Store(rootDirectoryURL: rootDir, storage: SQLiteStorage())StoreCoordinator creates its store with file storage. SQLiteStorage is not thread-safe, so use a SQLite-backed store from one queue or actor at a time. To write your own backend, conform to Storage and Zone.
An Exchange sends and receives versions between stores, the equivalent of git push and git pull. Set one on a coordinator and call exchange(), or drive it directly:
let retrievedIds = try await exchange.retrieve()
let sentIds = try await exchange.send()| Backend | Library | Notes |
|---|---|---|
CloudKitExchange |
LLVSCloudKit |
Private (default or custom zone) or public database. Shared databases do not work yet. Supports snapshots. |
CloudFileSystemExchange |
LLVS |
Works over any CloudFileSystem. Supports snapshots. |
WebDAVFileSystem |
LLVSWebDAV |
A CloudFileSystem. Base URL plus optional username and password. |
GoogleDriveFileSystem |
LLVSGoogleDrive |
A CloudFileSystem. Takes an access token or a GoogleDriveAuthenticator. |
OneDriveFileSystem |
LLVSOneDrive |
A CloudFileSystem. Takes an access token or a OneDriveAuthenticator. |
BoxExchange |
LLVSBox |
Wraps the Box SDK. Needs the Box trait. |
PCloudExchange |
LLVSPCloud |
Wraps the pCloud SDK. Needs the PCloud trait. |
FileSystemExchange |
LLVS |
A shared directory. Good for tests and syncing between processes. Supports snapshots. |
MemoryExchange |
LLVS |
In memory (an actor). For tests. |
MultipeerExchange |
LLVS |
Peer to peer, over a PeerTransport that you supply. |
A few examples:
// CloudKit, private database
let exchange = CloudKitExchange(
with: store,
storeIdentifier: "MyStore",
cloudDatabaseDescription: .privateDatabaseWithCustomZone(CKContainer.default(), zoneIdentifier: "MyZone")
)
// WebDAV
let webDAV = WebDAVFileSystem(baseURL: serverURL, username: "drew", password: password)
let exchange = CloudFileSystemExchange(cloudFileSystem: webDAV, store: store, basePath: "MyApp")
// A shared directory
let exchange = FileSystemExchange(rootDirectoryURL: sharedDirectoryURL, store: store, usesFileCoordination: false)CloudFileSystem is a small protocol (exists, list, upload, download, remove), so supporting another file-based service is not much work. MultipeerExchange does not depend on MultipeerConnectivity itself: you implement PeerTransport.send(_:toPeer:) to push bytes to the other peer, and call receiveData(_:) on the exchange when bytes arrive.
Every exchange has a newVersionsAvailable stream (AsyncStream<Void>) that you can use to trigger a sync when the backend is able to tell you something changed. For everything else, conform to Exchange yourself. You only need to implement the primitive operations; retrieve() and send() have default implementations that work out what is missing on each side and transfer it in batches.
When a new device joins, it normally downloads every version from the beginning and rebuilds the store's history. For stores with thousands of versions, this can be slow.
Cloud snapshots solve this by periodically uploading a chunked copy of the entire store. A new device downloads the snapshot, restores it locally, and then uses normal incremental sync to catch up with anything added since. Existing devices are unaffected.
let coordinator = try StoreCoordinator(
withStoreDirectoryAt: storeURL,
cacheDirectoryAt: cacheURL,
snapshotPolicy: .auto
)
coordinator.exchange = myExchange
// On first launch, try to restore from a snapshot before syncing
try? await coordinator.bootstrapFromSnapshot()
try? await coordinator.exchange()
_ = try? coordinator.merge()bootstrapFromSnapshot() checks whether the exchange and storage support snapshots, whether a compatible snapshot exists, and whether the local store is still empty. If so, it downloads and restores the snapshot. If not, it returns without doing anything, and the app falls back to a full sync with no extra code.
With SnapshotPolicy.auto, the coordinator uploads a new snapshot after an exchange when enough time has passed (minimumInterval, default 7 days) and enough new versions have accumulated (minimumNewVersions, default 20). You can build your own SnapshotPolicy with different numbers. The default is .disabled.
Snapshots need both sides to opt in. The storage must conform to SnapshotCapable (FileStorage and SQLiteStorage do), and the exchange to SnapshotExchange (FileSystemExchange, CloudFileSystemExchange, and CloudKitExchange do). If either side doesn't, snapshot operations are silently skipped.
The Samples directory has two SwiftUI apps. They are Xcode projects, not part of the package.
- TheMessage is a minimal app that syncs a single shared message via the public CloudKit database. Good for understanding the basics.
- LoCo is a contact book that uses
LLVSModel,@MergeableModel, andMergeableArbiter, and syncs via a private CloudKit zone.
LLVSModel value IDs changed shape. They are now "<instance-id>/<TypeName>" rather than "<TypeName>/<instance-id>".
The Map buckets values by the first two characters of their ID. With the type name leading, every instance of a type landed in one bucket, and each save rewrote a node listing all of them — O(N) per write, which does not hold once a store grows. The instance identifier now leads, so instances spread across buckets.
If you use modelValueID, modelTypeIdentifier(from:) and instanceIdentifier(from:), nothing in your code changes: the signatures are the same, and both accessors now split on the last slash rather than the first, so an instance identifier may itself contain slashes. If you built or parsed these IDs by hand, adjust.
An existing store keeps its old IDs and goes on working, crowded into one bucket as before. There is no migration, and objects written by this version do not match the IDs written by an earlier one, so do not point two builds at one synced store across the upgrade.
fetchAllModels now compares the type identifier rather than matching an ID prefix, so it scans every reference at the version. For a large store, project the type into LLVSProjection and query that instead.
The package builds in Swift 6 language mode. Ordinary use is unaffected, and both sample apps needed no changes, but four things break if you extend the framework.
ExchangeandZonerequireSendable. Your own conformances must be safe to use from more than one thread.CacherequiresSendablekeys and values. Its methods takesome Hashable & Sendableinstead ofAnyHashable.@Atomicis now@Guarded. The standard library has its ownAtomic, and the two names clashed.SQLiteDatabase.Error.bindingFailedcarries avalueDescription: Stringinstead of anAny?value.
There are two source breaks.
StoreCoordinator.merge()andStore.mergeHeads(into:resolvingWith:)now throw. A failed merge used to crash the app (it was atry!inside). Now you get the error. Addtry, ortry?if you just want to try again at the next sync.StoreCoordinator.merge()still attempts every head before throwing the first error it met.- Box and pCloud need traits. If you use
LLVSBoxorLLVSPCloud, addtraits: ["Box"]ortraits: ["PCloud"]to your.packageentry, otherwise the module will be empty. Traits need swift-tools-version 6.1 in your own manifest.