Repository: mentalfaculty/LLVS Branch: main Commit: 92fe81b57dc5 Files: 103 Total size: 535.0 KB Directory structure: gitextract_ytf8b50v/ ├── .gitignore ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENCE.txt ├── Package.swift ├── README.md ├── Samples/ │ ├── LoCo/ │ │ ├── LoCo/ │ │ │ ├── Assets.xcassets/ │ │ │ │ ├── AccentColor.colorset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── Contact.swift │ │ │ ├── ContactStore.swift │ │ │ ├── ContactView.swift │ │ │ ├── ContactsView.swift │ │ │ ├── LoCo.entitlements │ │ │ ├── LoCoApp.swift │ │ │ └── Preview Content/ │ │ │ └── Preview Assets.xcassets/ │ │ │ └── Contents.json │ │ └── LoCo.xcodeproj/ │ │ └── project.pbxproj │ └── TheMessage/ │ ├── TheMessage/ │ │ ├── Assets.xcassets/ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── ContentView.swift │ │ ├── MessageStore.swift │ │ ├── Preview Content/ │ │ │ └── Preview Assets.xcassets/ │ │ │ └── Contents.json │ │ ├── TheMessage.entitlements │ │ └── TheMessageApp.swift │ └── TheMessage.xcodeproj/ │ ├── project.pbxproj │ └── project.xcworkspace/ │ ├── contents.xcworkspacedata │ └── xcshareddata/ │ └── IDEWorkspaceChecks.plist ├── Sources/ │ ├── LLVS/ │ │ ├── Core/ │ │ │ ├── Exchange.swift │ │ │ ├── FileZone.swift │ │ │ ├── FolderBasedExchange.swift │ │ │ ├── History.swift │ │ │ ├── Map.swift │ │ │ ├── Merge.swift │ │ │ ├── Snapshot.swift │ │ │ ├── SnapshotCapable+ZIP.swift │ │ │ ├── Storage.swift │ │ │ ├── Store.swift │ │ │ ├── StoreCoordinator.swift │ │ │ ├── Value.swift │ │ │ ├── Version.swift │ │ │ └── Zone.swift │ │ ├── Exchanges/ │ │ │ ├── CloudFileSystem.swift │ │ │ ├── CloudFileSystemExchange.swift │ │ │ ├── FileSystemExchange.swift │ │ │ ├── MemoryExchange.swift │ │ │ └── MultipeerExchange.swift │ │ ├── General/ │ │ │ ├── Cache.swift │ │ │ ├── DataCompression.swift │ │ │ ├── DynamicTaskBatcher.swift │ │ │ ├── General.swift │ │ │ └── Log.swift │ │ └── Utilities/ │ │ └── ArrayDiff.swift │ ├── LLVSBox/ │ │ └── BoxExchange.swift │ ├── LLVSCloudKit/ │ │ └── CloudKitExchange.swift │ ├── LLVSGoogleDrive/ │ │ ├── GoogleDriveAuthenticator.swift │ │ └── GoogleDriveFileSystem.swift │ ├── LLVSModel/ │ │ ├── Macros.swift │ │ ├── Mergeable.swift │ │ ├── MergeableArbiter.swift │ │ ├── StorableModel.swift │ │ └── StoreCoordinator+Model.swift │ ├── LLVSModelMacros/ │ │ ├── MergeableModelMacro.swift │ │ └── Plugin.swift │ ├── LLVSOneDrive/ │ │ ├── OneDriveAuthenticator.swift │ │ └── OneDriveFileSystem.swift │ ├── LLVSPCloud/ │ │ └── PCloudExchange.swift │ ├── LLVSSQLite/ │ │ ├── SQLiteDatabase+Zones.swift │ │ ├── SQLiteDatabase.swift │ │ └── SQLiteZone.swift │ ├── LLVSWebDAV/ │ │ ├── WebDAVFileSystem.swift │ │ └── WebDAVResponseParser.swift │ └── SQLite3/ │ ├── module.modulemap │ └── shim.h ├── Tests/ │ ├── LLVSModelTests/ │ │ └── MergeableArbiterTests.swift │ └── LLVSTests/ │ ├── ArrayDiffTests.swift │ ├── CloudFileSystemExchangeTests.swift │ ├── DataCompressionTests.swift │ ├── DiffTests.swift │ ├── DynamicTaskBatcherTests.swift │ ├── FileSystemExchangeTests.swift │ ├── FileZoneTests.swift │ ├── GeneralTests.swift │ ├── HistoryTests.swift │ ├── MapTests.swift │ ├── MemoryExchangeTests.swift │ ├── MergeTests.swift │ ├── MostRecentBranchArbiterTests.swift │ ├── MostRecentChangeArbiterTests.swift │ ├── MultipeerExchangeTests.swift │ ├── PerformanceTests.swift │ ├── PrevailingValueTests.swift │ ├── SQLitePerformanceTests.swift │ ├── SQLiteZoneTests.swift │ ├── SerialHistoryTests.swift │ ├── SharedStoreTests.swift │ ├── SnapshotTests.swift │ ├── StoreSetupTests.swift │ ├── ValueChangesInVersionTests.swift │ ├── ValueTests.swift │ └── VersionTests.swift └── docs/ ├── _config.yml ├── _posts/ │ └── 2019-09-25-data-driven-swiftui.md └── index.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store /.build /.swiftpm **/Package.resolved /Packages *.pbxuser !default.pbxuser *.mode1v3 !default.mode1v3 *.mode2v3 !default.mode2v3 *.perspectivev3 !default.perspectivev3 xcuserdata/ *.moved-aside *.xccheckout *.xcscmblueprint .claude/ ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What is LLVS? LLVS (Low-Level Versioned Store) is a decentralized, versioned key-value storage framework — essentially Git for app data. It provides version-controlled data storage with branching, merging, and syncing across devices and processes (main app, extensions, watch apps). The data itself is opaque to the framework; apps store arbitrary `Data` blobs keyed by string identifiers. ## Build & Test Commands ```bash swift build # Build all targets swift test # Run all 170 tests swift test --filter LLVSTests.StoreSetupTests # Run a single test class swift test --filter testStoreCreatesDirectories # Run a single test method by name ``` Tests are in `Tests/LLVSTests/` and `Tests/LLVSModelTests/`, depending on `LLVS`, `LLVSSQLite`, and `LLVSModel`. There is no linter configured. Sample apps (in `Samples/`) are Xcode projects, not part of the SPM package. ## Package Structure SPM targets with a layered dependency graph: - **LLVS** — Core framework, zero dependencies. All fundamental types and logic. - **LLVSModel** — High-level model layer with `@MergeableModel` macro, `StorableModel` protocol, and `MergeableArbiter`. Depends on LLVS. - **LLVSModelMacros** — Swift macro implementation for `@MergeableModel`. Depends on SwiftSyntax. - **LLVSSQLite** — SQLite storage backend. Depends on LLVS and SQLite3. - **LLVSCloudKit** — CloudKit sync exchange. Depends on LLVS. - **SQLite3** — System library wrapper for SQLite. ## Architecture ### Core Data Flow `Store` is the central class. It owns a `History` (in-memory DAG of all versions), a `Map` (index mapping versions to their values), and a `Zone` (pluggable storage backend). All writes go through `Store.makeVersion()`, which atomically records a new version with its value changes. All reads go through `Store.value(id:at:)`, which resolves what value exists for a key at a given version by walking the map. `StoreCoordinator` wraps `Store` with convenience: it tracks the "current version" for the app UI, simplifies save/fetch, and orchestrates exchange + merge cycles. ### Version History (DAG) Versions form a directed acyclic graph. Each `Version` has 0-2 predecessors (0 for initial, 1 for linear, 2 for merge commits) and 0+ successors. "Heads" are versions with no successors — the branch tips. `History` provides traversal (topological sort via Kahn's algorithm), common ancestor finding, and head tracking. Access to `History` is serialized via `historyAccessQueue` — always use `store.queryHistory { history in ... }`. ### Merging Three-way merge is the primary merge strategy: find the greatest common ancestor of two heads, diff each head against it, then pass the forks to a `MergeArbiter` to resolve conflicts. The `MergeArbiter` protocol has a single method: `changes(toResolve:in:) throws -> [Value.Change]`. Built-in arbiters: `MostRecentBranchFavoringArbiter` (favors branch with newer timestamp), `MostRecentChangeFavoringArbiter` (favors most recent individual change), and `MergeableArbiter` (delegates to `Mergeable` types for property-wise 3-way merge). Fast-forward is used when one version is an ancestor of the other. `@MergeableModel` macro generates `Mergeable` conformance for structs, producing per-property merge via overloaded `mergeProperty`/`salvageProperty` helpers. Properties conforming to `Mergeable` get deep recursive merge; plain `Equatable` properties use simple equality checks. `Optional` where `Wrapped: Mergeable` also supports smart merge. `Value.Fork` describes per-value conflict states: `.inserted`, `.updated`, `.removed` (non-conflicting, single branch), `.twiceInserted`, `.twiceUpdated`, `.removedAndUpdated` (conflicting, require arbiter resolution). ### Storage Abstraction `Storage` protocol creates `Zone` instances. `Zone` is the raw read/write interface (`store(_:for:)` / `data(for:)`). Two implementations: - `FileZone` — hierarchical files on disk under the store's root directory. Uses 2-char prefix subdirectories for filesystem efficiency. Multi-process safe. - `SQLiteZone` — SQLite-backed storage via `LLVSSQLite`. Not thread-safe by design (caller manages concurrency). ### Sync (Exchange) `Exchange` protocol handles sending/receiving versions between stores. All exchange methods use async/await. The default `retrieve`/`send` implementations orchestrate the full sync flow: discover remote version IDs → find missing ones → fetch/push in batches (5MB chunks via `DynamicTaskBatcher`). Implementations: - `CloudKitExchange` — syncs via CloudKit (private, public, or shared databases). - `FileSystemExchange` — syncs via a shared filesystem directory (useful for testing). - `MemoryExchange` — actor-based in-memory exchange. - `MultipeerExchange` — peer-to-peer exchange using `PeerTransport` protocol. ### Map (Value Index) `Map` is a hierarchical tree that tracks which values exist at each version. Nodes are keyed by 2-character prefixes of value identifiers, forming a trie-like structure. This allows efficient "what values changed in this version?" queries without scanning all values. ### Key Value Types - `Value` — has an `ID` (string key) and `Data` payload, plus an optional `Reference` (version + key) for locating stored data. - `Value.Change` — enum: `.insert`, `.update`, `.remove`, `.preserve`, `.preserveRemoval`. These are what get stored per-version. - `Version.ID` — wrapper around a UUID string. - `Branch` — wrapper around a raw string, stored in version metadata. ================================================ FILE: CONTRIBUTING.md ================================================ ### Low-Level Versioned Store (LLVS) – Contributor License Agreement (v1) Thank you for your interest in the Low-Level Versioned Store [LLVS] (the "Project"). In order to clarify the intellectual property license granted with Contributions from any person or entity, the Project must have a Contributor License Agreement ("CLA") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of the Project and its users; it does not change your rights to use your own Contributions for any other purpose. If you have not already done so, please complete and sign, then submit this Agreement by filling this electronic form and pressing the Submit button at the end of this electronic form. Please read this document carefully before signing and keep a copy for your records. You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the Project. In return, the Project shall not use Your Contributions in a way that is contrary to the public benefit. Except for the license granted herein to the Project and recipients of software distributed by the Project, You reserve all right, title, and interest in and to Your Contributions. 1. Definitions. "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with the Project. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the Project for inclusion in, or documentation of, any of the products owned or managed by the Project (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Project or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." 2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to the Project and to recipients of software distributed by the Project a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, distribute, and otherwise exploit in any manner Your Contributions and such derivative works. 3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to the Project and to recipients of software distributed by the Project a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, exploit in any manner, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. 4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to the Project, or that your employer has executed a separate Corporate CLA with the Project. 5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. 6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. 7. Should You wish to submit work that is not Your original creation, You may submit it to the Project separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". 8. You agree to notify the Project of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. ================================================ FILE: LICENCE.txt ================================================ Copyright (c) 2019 Drew McCormack Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Package.swift ================================================ // swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription import CompilerPluginSupport let package = Package( name: "LLVS", platforms: [ .macOS(.v15), .iOS(.v18), .watchOS(.v11) ], products: [ .library( name: "SQLite3", targets: ["SQLite3"]), .library( name: "LLVS", targets: ["LLVS"]), .library( name: "LLVSCloudKit", targets: ["LLVSCloudKit"]), .library( name: "LLVSSQLite", targets: ["LLVSSQLite"]), .library( name: "LLVSPCloud", targets: ["LLVSPCloud"]), .library( name: "LLVSBox", targets: ["LLVSBox"]), .library( name: "LLVSModel", targets: ["LLVSModel"]), .library( name: "LLVSWebDAV", targets: ["LLVSWebDAV"]), .library( name: "LLVSGoogleDrive", targets: ["LLVSGoogleDrive"]), .library( name: "LLVSOneDrive", targets: ["LLVSOneDrive"]), ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation.git", .upToNextMajor(from: "0.9.0")), .package(url: "https://github.com/pCloud/pcloud-sdk-swift.git", from: "3.0.0"), .package(url: "https://github.com/box/box-ios-sdk.git", from: "10.0.0"), .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0"), ], targets: [ .systemLibrary( name: "SQLite3" ), .target( name: "LLVS", dependencies: [ .product(name: "ZIPFoundation", package: "ZIPFoundation"), ], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget( name: "LLVSTests", dependencies: ["LLVS", "LLVSSQLite"], swiftSettings: [.swiftLanguageMode(.v5)]), .target( name: "LLVSCloudKit", dependencies: ["LLVS"], swiftSettings: [.swiftLanguageMode(.v5)]), .target( name: "LLVSSQLite", dependencies: ["LLVS", "SQLite3"], swiftSettings: [.swiftLanguageMode(.v5)]), .target( name: "LLVSPCloud", dependencies: [ "LLVS", .product(name: "PCloudSDKSwift", package: "pcloud-sdk-swift") ], swiftSettings: [.swiftLanguageMode(.v5)]), .target( name: "LLVSBox", dependencies: [ "LLVS", .product(name: "BoxSDK", package: "box-ios-sdk") ], swiftSettings: [.swiftLanguageMode(.v5)]), .macro( name: "LLVSModelMacros", dependencies: [ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), ]), .target( name: "LLVSModel", dependencies: [ "LLVS", "LLVSModelMacros", ], swiftSettings: [.swiftLanguageMode(.v5)]), .target( name: "LLVSWebDAV", dependencies: ["LLVS"], swiftSettings: [.swiftLanguageMode(.v5)]), .target( name: "LLVSGoogleDrive", dependencies: ["LLVS"], swiftSettings: [.swiftLanguageMode(.v5)]), .target( name: "LLVSOneDrive", dependencies: ["LLVS"], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget( name: "LLVSModelTests", dependencies: [ "LLVSModel", "LLVS", "LLVSSQLite", ], swiftSettings: [.swiftLanguageMode(.v5)]) ] ) ================================================ FILE: README.md ================================================ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmentalfaculty%2FLLVS%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/mentalfaculty/LLVS) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmentalfaculty%2FLLVS%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/mentalfaculty/LLVS) # Low-Level Versioned Store (LLVS) _Author: Drew McCormack ([@drewmccormack](https://github.com/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. ### The problem 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. Keeping these in sync is the kind of problem that consumes months of development time. You end up writing custom conflict detection, manual diffing, retry logic, and timestamp heuristics -- and it's still fragile. 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. The framework does the hard part; you just decide what "resolve this conflict" means for your data. ### What you get - **Version history** -- Every save is a version. Branch, merge, diff, or revert to any point in time. - **Three-way merge** -- When versions diverge, LLVS finds their common ancestor and diffs both sides. You provide a `MergeArbiter` to resolve conflicts however you like, or use a built-in one. - **Sync without networking code** -- Push and pull versions between stores via CloudKit, a shared filesystem, or your own custom exchange. Attach multiple exchanges to the same store. - **Multi-process safe** -- Share a store between your main app, extensions, and widgets using an app group container. LLVS handles concurrent access. - **Pluggable storage** -- File-based storage by default, SQLite via `LLVSSQLite`, or bring your own backend. - **Encryption-friendly** -- LLVS stores opaque `Data` blobs. Encrypt them however you want; the framework never inspects your data. ## Quick Start This walks through a minimal app that syncs a shared message via CloudKit. Full code is in _Samples/TheMessage_. ### Set up a StoreCoordinator `StoreCoordinator` is the simplest entry point. It wraps a `Store`, tracks the current version, and orchestrates sync and merging. ```swift lazy var storeCoordinator: StoreCoordinator = { let coordinator = try! StoreCoordinator() let container = CKContainer(identifier: "iCloud.com.mycompany.themessage") let exchange = CloudKitExchange( with: coordinator.store, storeIdentifier: "MainStore", cloudDatabaseDescription: .publicDatabase(container) ) coordinator.exchange = exchange return coordinator }() ``` ### Save, fetch, sync ```swift let messageId = Value.ID("MESSAGE") func post(message: String) { let value = Value(id: messageId, data: message.data(using: .utf8)!) try! storeCoordinator.save(updating: [value]) sync() } func fetchMessage() -> String? { guard let value = try? storeCoordinator.value(id: messageId) else { return nil } return String(data: value.data, encoding: .utf8) } func sync() { storeCoordinator.exchange { _ in self.storeCoordinator.merge() } } ``` `exchange` sends and receives versions with the cloud. `merge` reconciles any concurrent changes. That's the entire sync implementation. This example is deliberately minimal -- the real power of LLVS shows up when data diverges across devices, which is covered below. ## Installation ### Swift Package Manager Add LLVS as a dependency in your `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/mentalfaculty/LLVS.git", from: "0.3.0") ] ``` Then add the libraries you need: `LLVS` for the core framework, `LLVSSQLite` for SQLite-backed storage, and `LLVSCloudKit` for CloudKit sync. ### Xcode Choose _File > Add Package Dependencies..._, enter the LLVS repository URL, and select the libraries your target needs. ### Platforms macOS 10.15+, iOS 13+, watchOS 6+. ## Working with `Store` `StoreCoordinator` is convenient for common cases, but `Store` gives you direct access to the version graph -- branching, merging, diffing, and time travel. ### Creating a Store ```swift 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. For SQLite-backed storage: ```swift let store = try Store(rootDirectoryURL: rootDir, storage: SQLiteStorage()) ``` ### Versions and values Every write creates a new version: ```swift 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 the initial version -- like Git's first commit. Subsequent changes build on a predecessor: ```swift let updated = Value(idString: "ABCDEF", data: "World".data(using: .utf8)!) let secondVersion = try store.makeVersion( basedOnPredecessor: firstVersion.id, updating: [updated] ) ``` Inserts, updates, and removes can be combined in a single call: ```swift let thirdVersion = try store.makeVersion( basedOnPredecessor: secondVersion.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: ```swift let value = try store.value(idString: "ABCDEF", at: secondVersion.id)! ``` ### Branching and heads 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 into branches. This isn't an error; it's the normal state of decentralized data. The branches get reconciled through merging. Each `Version` can have up to two predecessors (one for linear history, two for merge commits) and any number of successors. A _head_ is a version with no successors -- the tip of a branch. When multiple heads exist, they generally need to be merged. ```swift store.queryHistory { history in let heads = history.headIdentifiers // ... } // Or get the most recent head directly: let latest: Version? = store.mostRecentHead ``` ### Merging This is where it gets interesting. When two versions have diverged, LLVS performs a three-way merge: it finds the greatest common ancestor, diffs each branch against it, and hands the results to a `MergeArbiter` that you provide. The arbiter decides how to resolve every conflict. ```swift 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. LLVS ships with two built-in arbiters: - `MostRecentChangeFavoringArbiter` -- resolves each conflict individually by keeping whichever change is newer. - `MostRecentBranchFavoringArbiter` -- resolves all conflicts by favoring whichever branch has the newer timestamp. For full control, implement the `MergeArbiter` protocol: ```swift public protocol MergeArbiter { func changes(toResolve merge: Merge, in store: Store) throws -> [Value.Change] } ``` The `Merge` object gives you a dictionary of `Value.Fork` entries describing per-value conflict states: `.inserted`, `.updated`, `.removed` (non-conflicting, single branch), `.twiceInserted`, `.twiceUpdated`, `.removedAndUpdated` (conflicting, both branches changed). Your arbiter returns `Value.Change` entries that resolve all the conflicting forks. This is where you encode your app's domain logic -- maybe the longer text wins, maybe you concatenate both, maybe you prompt the user. ### Sync (Exchange) An `Exchange` sends and receives versions between stores -- the equivalent of `git push` and `git pull`. **CloudKit** (via the `LLVSCloudKit` library): ```swift let exchange = CloudKitExchange( with: store, storeIdentifier: "MyStore", cloudDatabaseDescription: .privateDatabaseWithCustomZone( CKContainer.default(), zoneIdentifier: "MyZone" ) ) ``` **File system** (useful for testing and inter-process sync): ```swift let exchange = FileSystemExchange( rootDirectoryURL: sharedDirectoryURL, store: store ) ``` Retrieving and sending are both asynchronous: ```swift exchange.retrieve { result in /* handle result */ } exchange.send { result in /* handle result */ } ``` You can attach multiple exchanges to a single store, syncing via different routes simultaneously -- CloudKit for cross-device, a shared directory for inter-process. You can also implement custom exchanges by conforming to the `Exchange` protocol. ## Structuring Your Data LLVS stores opaque `Data` blobs keyed by string identifiers. 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. It gives you per-entity conflict resolution while keeping read performance reasonable. Use `Codable`, JSON, flatbuffers, or whatever serialization you prefer -- LLVS never inspects the bytes. ## Snapshots When a new device joins your sync group, it normally replays every version from the beginning -- downloading each change and rebuilding the store's history. For stores with thousands of versions, this can be slow. **Cloud snapshots** solve this by periodically uploading a chunked dump of the entire store. A new device downloads the snapshot, restores it locally, and then uses normal incremental sync to catch up with any versions added since the snapshot. Existing devices are completely unaffected. ### Bootstrapping a new device ```swift let coordinator = try StoreCoordinator( withStoreDirectoryAt: storeURL, cacheDirectoryAt: cacheURL, snapshotPolicy: .auto ) coordinator.exchange = myExchange // On first launch, try to restore from a snapshot before syncing coordinator.bootstrapFromSnapshot { error in coordinator.exchange { _ in coordinator.merge() } } ``` `bootstrapFromSnapshot()` checks whether the exchange supports snapshots, whether one exists, and whether the local store is empty. If all conditions are met, it downloads and restores the snapshot. If not, it completes immediately -- the app falls back to a full sync with no extra code. ### Automatic snapshot uploads With `SnapshotPolicy.auto`, the coordinator uploads a new snapshot after each exchange when enough time has passed (`minimumInterval`, default 7 days) and enough new versions have accumulated (`minimumNewVersions`, default 20). Use `.disabled` (the default) to opt out. ### Custom storage and exchange support Snapshot support requires both the storage backend and the exchange to opt in: - **Storage**: Conform to `SnapshotCapable` (both `FileStorage` and `SQLiteStorage` already do). - **Exchange**: Conform to `SnapshotExchange` (`FileSystemExchange` already does). If either side doesn't conform, snapshot operations are silently skipped. ## Architecture This section covers the internal design for contributors and anyone who wants to understand what's happening under the hood. ### Package structure LLVS is split into four SPM targets: | Target | Purpose | Dependencies | |---|---|---| | **LLVS** | Core framework: `Store`, `History`, `Map`, `Zone`, `Exchange`, `Value`, `Version` | None | | **LLVSSQLite** | SQLite storage backend | LLVS, SQLite3 | | **LLVSCloudKit** | CloudKit sync exchange | LLVS | | **SQLite3** | System library wrapper | System SQLite | ### Core data flow `Store` is the central class. It owns three components: - **History** -- An in-memory directed acyclic graph (DAG) of all versions. Provides topological traversal (Kahn's algorithm), common ancestor finding, and head tracking. Access is serialized via `historyAccessQueue`. - **Map** -- A hierarchical trie-like index that tracks which values exist at each version. Nodes are keyed by 2-character prefixes of value identifiers, making "what values exist at this version?" queries efficient without scanning everything. - **Zone** -- A pluggable storage backend for reading and writing raw data. All writes go through `Store.makeVersion()`, which atomically records a new version with its value changes. All reads go through `Store.value(id:at:)`, which walks the map to find where a value's data is physically stored. ### Storage abstraction The `Storage` protocol creates `Zone` instances. `Zone` is the raw read/write interface with two methods: `store(_:for:)` and `data(for:)`. - **FileZone** -- Files on disk, using 2-character prefix subdirectories for filesystem efficiency. Multi-process safe. - **SQLiteZone** -- SQLite-backed. Not thread-safe by design (the caller manages concurrency). Available via `LLVSSQLite`. ### Map internals The Map is a two-level trie. The root node for each version points to subnodes keyed by the first two characters of value identifiers. Each subnode maps value IDs to `Value.Reference` (which records the version where the data is physically stored). Subnodes are shared across versions -- if a version doesn't modify any values in a particular bucket, it reuses the parent's subnode. This makes versioning space-efficient. ## Samples The _Samples_ directory includes three example projects: - **TheMessage** -- A minimal app that syncs a single shared message via CloudKit. Good for understanding the basics. - **LoCo** -- A contact book app using UIKit. - **LoCo-SwiftUI** -- The same contact book app built with SwiftUI. ## Learning More There are useful posts at the [LLVS Blog](https://mentalfaculty.github.io/LLVS/). ================================================ FILE: Samples/LoCo/LoCo/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Samples/LoCo/LoCo/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Samples/LoCo/LoCo/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Samples/LoCo/LoCo/Contact.swift ================================================ // // Contact.swift // LoCo // // Created by Drew McCormack on 04/03/2026. // import Foundation 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 streetAddress: String = "" var postCode: String = "" var city: String = "" var country: String = "" var avatarJPEGData: Data? var fullName: String { switch (firstName.isEmpty, lastName.isEmpty) { case (true, true): return "" case (true, false): return lastName case (false, true): return firstName case (false, false): return "\(firstName) \(lastName)" } } var fullNameOrPlaceholder: String { fullName.isEmpty ? "New Contact" : fullName } } ================================================ FILE: Samples/LoCo/LoCo/ContactStore.swift ================================================ // // ContactStore.swift // LoCo // // Created by Drew McCormack on 04/03/2026. // import Foundation import LLVS import LLVSModel import LLVSCloudKit import CloudKit @MainActor @Observable class ContactStore { var contacts: [Contact] = [] private let storeCoordinator: StoreCoordinator @ObservationIgnored nonisolated(unsafe) private var versionTask: Task? @ObservationIgnored nonisolated(unsafe) private var pollingTask: Task? init() { LLVS.log.level = .verbose let coordinator = try! StoreCoordinator() let arbiter = MergeableArbiter() arbiter.register(Contact.self) coordinator.mergeArbiter = arbiter let container = CKContainer(identifier: "iCloud.com.mentalfaculty.loco") let exchange = CloudKitExchange(with: coordinator.store, storeIdentifier: "MainStore", cloudDatabaseDescription: .privateDatabaseWithCustomZone(container, zoneIdentifier: "LoCo")) coordinator.exchange = exchange self.storeCoordinator = coordinator contacts = fetchContacts() versionTask = Task { [weak self] in guard let self else { return } for await _ in coordinator.currentVersionUpdates { self.contacts = self.fetchContacts() } } startPolling() } private func fetchContacts() -> [Contact] { (try? storeCoordinator.fetchAllModels(Contact.self)) ?? [] } func addContact() -> Contact { let contact = Contact() try! storeCoordinator.save(contact, instanceIdentifier: contact.id.uuidString) sync() return contact } func updateContact(_ contact: Contact) { try! storeCoordinator.save(contact, instanceIdentifier: contact.id.uuidString) sync() } func deleteContact(_ contact: Contact) { try! storeCoordinator.removeModel(Contact.self, instanceIdentifier: contact.id.uuidString) sync() } func sync() { Task { try? await storeCoordinator.exchange() storeCoordinator.merge() } } private func startPolling() { pollingTask = Task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(15)) try? await storeCoordinator.exchange() storeCoordinator.merge() } } } deinit { versionTask?.cancel() pollingTask?.cancel() } } ================================================ FILE: Samples/LoCo/LoCo/ContactView.swift ================================================ // // ContactView.swift // LoCo // // Created by Drew McCormack on 04/03/2026. // import SwiftUI struct ContactView: View { @Environment(ContactStore.self) var store let contactId: UUID private var contact: Contact? { store.contacts.first(where: { $0.id == contactId }) } var body: some View { if var contact { Form { Section("Name") { TextField("First Name", text: binding(for: \.firstName, on: &contact)) TextField("Last Name", text: binding(for: \.lastName, on: &contact)) } Section("Address") { TextField("Street", text: binding(for: \.streetAddress, on: &contact)) TextField("Post Code", text: binding(for: \.postCode, on: &contact)) TextField("City", text: binding(for: \.city, on: &contact)) TextField("Country", text: binding(for: \.country, on: &contact)) } } .navigationTitle(contact.fullNameOrPlaceholder) } else { ContentUnavailableView("Contact Not Found", systemImage: "person.slash") } } private func binding(for keyPath: WritableKeyPath, on contact: inout Contact) -> Binding { Binding( get: { self.contact?[keyPath: keyPath] ?? "" }, set: { newValue in if var updated = self.contact { updated[keyPath: keyPath] = newValue store.updateContact(updated) } } ) } } ================================================ FILE: Samples/LoCo/LoCo/ContactsView.swift ================================================ // // ContactsView.swift // LoCo // // Created by Drew McCormack on 04/03/2026. // import SwiftUI struct ContactsView: View { @Environment(ContactStore.self) var store var body: some View { NavigationStack { List { ForEach(store.contacts.sorted(by: { $0.fullNameOrPlaceholder < $1.fullNameOrPlaceholder })) { contact in NavigationLink(value: contact.id) { VStack(alignment: .leading) { Text(contact.fullNameOrPlaceholder) .font(.headline) if !contact.city.isEmpty { Text(contact.city) .font(.subheadline) .foregroundStyle(.secondary) } } } } .onDelete(perform: deleteContacts) } .navigationTitle("Contacts") .navigationDestination(for: UUID.self) { contactId in ContactView(contactId: contactId) } .toolbar { ToolbarItem(placement: .primaryAction) { Button { _ = store.addContact() } label: { Image(systemName: "plus") } } } } } private func deleteContacts(at offsets: IndexSet) { let sorted = store.contacts.sorted(by: { $0.fullNameOrPlaceholder < $1.fullNameOrPlaceholder }) for index in offsets { store.deleteContact(sorted[index]) } } } ================================================ FILE: Samples/LoCo/LoCo/LoCo.entitlements ================================================ aps-environment development com.apple.developer.icloud-container-identifiers iCloud.com.mentalfaculty.loco com.apple.developer.icloud-services CloudKit ================================================ FILE: Samples/LoCo/LoCo/LoCoApp.swift ================================================ // // LoCoApp.swift // LoCo // // Created by Drew McCormack on 04/03/2026. // import SwiftUI @main struct LoCoApp: App { @State private var store = ContactStore() var body: some Scene { WindowGroup { ContactsView() .environment(store) } } } ================================================ FILE: Samples/LoCo/LoCo/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Samples/LoCo/LoCo.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 60; objects = { /* Begin PBXBuildFile section */ AA0001012D97B1E200000001 /* LoCoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002022D97B1E200000001 /* LoCoApp.swift */; }; AA0001022D97B1E200000001 /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002032D97B1E200000001 /* Contact.swift */; }; AA0001042D97B1E200000001 /* ContactStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002052D97B1E200000001 /* ContactStore.swift */; }; AA0001052D97B1E200000001 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002062D97B1E200000001 /* ContactsView.swift */; }; AA0001062D97B1E200000001 /* ContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002072D97B1E200000001 /* ContactView.swift */; }; AA0001072D97B1E200000001 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0002082D97B1E200000001 /* Assets.xcassets */; }; AA0001082D97B1E200000001 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0002092D97B1E200000001 /* Preview Assets.xcassets */; }; AA0001092D97B1E200000001 /* LLVS in Frameworks */ = {isa = PBXBuildFile; productRef = AA000A012D97B1E200000001 /* LLVS */; }; AA00010A2D97B1E200000001 /* LLVSCloudKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA000A022D97B1E200000001 /* LLVSCloudKit */; }; AA00010B2D97B1E200000001 /* LLVSModel in Frameworks */ = {isa = PBXBuildFile; productRef = AA000A032D97B1E200000001 /* LLVSModel */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ AA0002012D97B1E200000001 /* LoCo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LoCo.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA0002022D97B1E200000001 /* LoCoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoCoApp.swift; sourceTree = ""; }; AA0002032D97B1E200000001 /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; AA0002052D97B1E200000001 /* ContactStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactStore.swift; sourceTree = ""; }; AA0002062D97B1E200000001 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = ""; }; AA0002072D97B1E200000001 /* ContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactView.swift; sourceTree = ""; }; AA0002082D97B1E200000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; AA0002092D97B1E200000001 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; AA00020A2D97B1E200000001 /* LoCo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoCo.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ AA0004012D97B1E200000001 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( AA0001092D97B1E200000001 /* LLVS in Frameworks */, AA00010A2D97B1E200000001 /* LLVSCloudKit in Frameworks */, AA00010B2D97B1E200000001 /* LLVSModel in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ AA0003012D97B1E200000001 = { isa = PBXGroup; children = ( AA0003032D97B1E200000001 /* LoCo */, AA0003022D97B1E200000001 /* Products */, ); sourceTree = ""; }; AA0003022D97B1E200000001 /* Products */ = { isa = PBXGroup; children = ( AA0002012D97B1E200000001 /* LoCo.app */, ); name = Products; sourceTree = ""; }; AA0003032D97B1E200000001 /* LoCo */ = { isa = PBXGroup; children = ( AA00020A2D97B1E200000001 /* LoCo.entitlements */, AA0002022D97B1E200000001 /* LoCoApp.swift */, AA0002032D97B1E200000001 /* Contact.swift */, AA0002052D97B1E200000001 /* ContactStore.swift */, AA0002062D97B1E200000001 /* ContactsView.swift */, AA0002072D97B1E200000001 /* ContactView.swift */, AA0002082D97B1E200000001 /* Assets.xcassets */, AA0003042D97B1E200000001 /* Preview Content */, ); path = LoCo; sourceTree = ""; }; AA0003042D97B1E200000001 /* Preview Content */ = { isa = PBXGroup; children = ( AA0002092D97B1E200000001 /* Preview Assets.xcassets */, ); path = "Preview Content"; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ AA0005012D97B1E200000001 /* LoCo */ = { isa = PBXNativeTarget; buildConfigurationList = AA0008022D97B1E200000001 /* Build configuration list for PBXNativeTarget "LoCo" */; buildPhases = ( AA0004032D97B1E200000001 /* Sources */, AA0004012D97B1E200000001 /* Frameworks */, AA0004022D97B1E200000001 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = LoCo; packageProductDependencies = ( AA000A012D97B1E200000001 /* LLVS */, AA000A022D97B1E200000001 /* LLVSCloudKit */, AA000A032D97B1E200000001 /* LLVSModel */, ); productName = LoCo; productReference = AA0002012D97B1E200000001 /* LoCo.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ AA0006012D97B1E200000001 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 2630; ORGANIZATIONNAME = "Momenta B.V."; TargetAttributes = { AA0005012D97B1E200000001 = { CreatedOnToolsVersion = 16.0; }; }; }; buildConfigurationList = AA0008012D97B1E200000001 /* Build configuration list for PBXProject "LoCo" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = AA0003012D97B1E200000001; packageReferences = ( AA0009012D97B1E200000001 /* XCLocalSwiftPackageReference "../.." */, ); productRefGroup = AA0003022D97B1E200000001 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( AA0005012D97B1E200000001 /* LoCo */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ AA0004022D97B1E200000001 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( AA0001082D97B1E200000001 /* Preview Assets.xcassets in Resources */, AA0001072D97B1E200000001 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ AA0004032D97B1E200000001 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( AA0001012D97B1E200000001 /* LoCoApp.swift in Sources */, AA0001022D97B1E200000001 /* Contact.swift in Sources */, AA0001042D97B1E200000001 /* ContactStore.swift in Sources */, AA0001052D97B1E200000001 /* ContactsView.swift in Sources */, AA0001062D97B1E200000001 /* ContactView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ AA0007012D97B1E200000001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; AA0007022D97B1E200000001 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; AA0007032D97B1E200000001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = LoCo/LoCo.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"LoCo/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.mentalfaculty.loco; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; AA0007042D97B1E200000001 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = LoCo/LoCo.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"LoCo/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.mentalfaculty.loco; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ AA0008012D97B1E200000001 /* Build configuration list for PBXProject "LoCo" */ = { isa = XCConfigurationList; buildConfigurations = ( AA0007012D97B1E200000001 /* Debug */, AA0007022D97B1E200000001 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; AA0008022D97B1E200000001 /* Build configuration list for PBXNativeTarget "LoCo" */ = { isa = XCConfigurationList; buildConfigurations = ( AA0007032D97B1E200000001 /* Debug */, AA0007042D97B1E200000001 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ AA0009012D97B1E200000001 /* XCLocalSwiftPackageReference "../.." */ = { isa = XCLocalSwiftPackageReference; relativePath = ../..; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ AA000A012D97B1E200000001 /* LLVS */ = { isa = XCSwiftPackageProductDependency; package = AA0009012D97B1E200000001 /* XCLocalSwiftPackageReference "../.." */; productName = LLVS; }; AA000A022D97B1E200000001 /* LLVSCloudKit */ = { isa = XCSwiftPackageProductDependency; package = AA0009012D97B1E200000001 /* XCLocalSwiftPackageReference "../.." */; productName = LLVSCloudKit; }; AA000A032D97B1E200000001 /* LLVSModel */ = { isa = XCSwiftPackageProductDependency; package = AA0009012D97B1E200000001 /* XCLocalSwiftPackageReference "../.." */; productName = LLVSModel; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = AA0006012D97B1E200000001 /* Project object */; } ================================================ FILE: Samples/TheMessage/TheMessage/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "iphone", "size" : "20x20", "scale" : "2x" }, { "idiom" : "iphone", "size" : "20x20", "scale" : "3x" }, { "idiom" : "iphone", "size" : "29x29", "scale" : "2x" }, { "idiom" : "iphone", "size" : "29x29", "scale" : "3x" }, { "idiom" : "iphone", "size" : "40x40", "scale" : "2x" }, { "idiom" : "iphone", "size" : "40x40", "scale" : "3x" }, { "idiom" : "iphone", "size" : "60x60", "scale" : "2x" }, { "idiom" : "iphone", "size" : "60x60", "scale" : "3x" }, { "idiom" : "ipad", "size" : "20x20", "scale" : "1x" }, { "idiom" : "ipad", "size" : "20x20", "scale" : "2x" }, { "idiom" : "ipad", "size" : "29x29", "scale" : "1x" }, { "idiom" : "ipad", "size" : "29x29", "scale" : "2x" }, { "idiom" : "ipad", "size" : "40x40", "scale" : "1x" }, { "idiom" : "ipad", "size" : "40x40", "scale" : "2x" }, { "idiom" : "ipad", "size" : "76x76", "scale" : "1x" }, { "idiom" : "ipad", "size" : "76x76", "scale" : "2x" }, { "idiom" : "ipad", "size" : "83.5x83.5", "scale" : "2x" }, { "idiom" : "ios-marketing", "size" : "1024x1024", "scale" : "1x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Samples/TheMessage/TheMessage/Assets.xcassets/Contents.json ================================================ { "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Samples/TheMessage/TheMessage/ContentView.swift ================================================ import SwiftUI struct ContentView: View { @Environment(MessageStore.self) var store @State private var draft: String = "" var body: some View { VStack(spacing: 20) { Text(store.message) .font(.title2) .multilineTextAlignment(.center) .padding() HStack { TextField("New message", text: $draft) .textFieldStyle(.roundedBorder) Button("Post") { guard !draft.isEmpty else { return } store.post(message: draft) draft = "" } .buttonStyle(.borderedProminent) } .padding(.horizontal) } } } ================================================ FILE: Samples/TheMessage/TheMessage/MessageStore.swift ================================================ import Foundation import LLVS import LLVSCloudKit import CloudKit @MainActor @Observable class MessageStore { var message: String = "Let there be light!" private let storeCoordinator: StoreCoordinator private let messageId = Value.ID("MESSAGE") @ObservationIgnored nonisolated(unsafe) private var versionTask: Task? @ObservationIgnored nonisolated(unsafe) private var pollingTask: Task? init() { LLVS.log.level = .verbose let coordinator = try! StoreCoordinator() let container = CKContainer(identifier: "iCloud.com.mentalfaculty.themessage") let exchange = CloudKitExchange(with: coordinator.store, storeIdentifier: "MainStore", cloudDatabaseDescription: .publicDatabase(container)) coordinator.exchange = exchange self.storeCoordinator = coordinator versionTask = Task { [weak self] in guard let self else { return } for await _ in coordinator.currentVersionUpdates { self.message = self.fetchMessage() ?? "Let there be light!" } } startPolling() } private func fetchMessage() -> String? { guard let value = try? storeCoordinator.value(id: messageId) else { return nil } return String(data: value.data, encoding: .utf8) } func post(message: String) { let data = message.data(using: .utf8)! let newValue = Value(id: messageId, data: data) try! storeCoordinator.save(updating: [newValue]) sync() } func sync() { Task { try? await storeCoordinator.exchange() storeCoordinator.merge() } } private func startPolling() { pollingTask = Task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(15)) try? await storeCoordinator.exchange() storeCoordinator.merge() } } } deinit { versionTask?.cancel() pollingTask?.cancel() } } ================================================ FILE: Samples/TheMessage/TheMessage/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Samples/TheMessage/TheMessage/TheMessage.entitlements ================================================ aps-environment development com.apple.developer.icloud-container-identifiers iCloud.com.mentalfaculty.themessage com.apple.developer.icloud-services CloudKit ================================================ FILE: Samples/TheMessage/TheMessage/TheMessageApp.swift ================================================ import SwiftUI @main struct TheMessageApp: App { @State private var store = MessageStore() var body: some Scene { WindowGroup { ContentView() .environment(store) } } } ================================================ FILE: Samples/TheMessage/TheMessage.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 60; objects = { /* Begin PBXBuildFile section */ 07E880462350EDE5003471B4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E880452350EDE5003471B4 /* ContentView.swift */; }; 07E880482350EDE6003471B4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07E880472350EDE6003471B4 /* Assets.xcassets */; }; 07E8804B2350EDE6003471B4 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07E8804A2350EDE6003471B4 /* Preview Assets.xcassets */; }; 07E8805E2350EF64003471B4 /* LLVS in Frameworks */ = {isa = PBXBuildFile; productRef = 07E8805D2350EF64003471B4 /* LLVS */; }; 07E880602350EF64003471B4 /* LLVSCloudKit in Frameworks */ = {isa = PBXBuildFile; productRef = 07E8805F2350EF64003471B4 /* LLVSCloudKit */; }; 07E880632350EF64003471B4 /* TheMessageApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E880612350EF64003471B4 /* TheMessageApp.swift */; }; 07E880642350EF64003471B4 /* MessageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E880622350EF64003471B4 /* MessageStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 07E8803E2350EDE5003471B4 /* TheMessage.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TheMessage.app; sourceTree = BUILT_PRODUCTS_DIR; }; 07E880452350EDE5003471B4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 07E880472350EDE6003471B4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 07E8804A2350EDE6003471B4 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 07E880552350EE21003471B4 /* TheMessage.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TheMessage.entitlements; sourceTree = ""; }; 07E880612350EF64003471B4 /* TheMessageApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheMessageApp.swift; sourceTree = ""; }; 07E880622350EF64003471B4 /* MessageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 07E8803B2350EDE5003471B4 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 07E8805E2350EF64003471B4 /* LLVS in Frameworks */, 07E880602350EF64003471B4 /* LLVSCloudKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 07E880352350EDE5003471B4 = { isa = PBXGroup; children = ( 07E880402350EDE5003471B4 /* TheMessage */, 07E8803F2350EDE5003471B4 /* Products */, ); sourceTree = ""; }; 07E8803F2350EDE5003471B4 /* Products */ = { isa = PBXGroup; children = ( 07E8803E2350EDE5003471B4 /* TheMessage.app */, ); name = Products; sourceTree = ""; }; 07E880402350EDE5003471B4 /* TheMessage */ = { isa = PBXGroup; children = ( 07E880552350EE21003471B4 /* TheMessage.entitlements */, 07E880612350EF64003471B4 /* TheMessageApp.swift */, 07E880622350EF64003471B4 /* MessageStore.swift */, 07E880452350EDE5003471B4 /* ContentView.swift */, 07E880472350EDE6003471B4 /* Assets.xcassets */, 07E880492350EDE6003471B4 /* Preview Content */, ); path = TheMessage; sourceTree = ""; }; 07E880492350EDE6003471B4 /* Preview Content */ = { isa = PBXGroup; children = ( 07E8804A2350EDE6003471B4 /* Preview Assets.xcassets */, ); path = "Preview Content"; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 07E8803D2350EDE5003471B4 /* TheMessage */ = { isa = PBXNativeTarget; buildConfigurationList = 07E880522350EDE6003471B4 /* Build configuration list for PBXNativeTarget "TheMessage" */; buildPhases = ( 07E8803A2350EDE5003471B4 /* Sources */, 07E8803B2350EDE5003471B4 /* Frameworks */, 07E8803C2350EDE5003471B4 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = TheMessage; packageProductDependencies = ( 07E8805D2350EF64003471B4 /* LLVS */, 07E8805F2350EF64003471B4 /* LLVSCloudKit */, ); productName = TheMessage; productReference = 07E8803E2350EDE5003471B4 /* TheMessage.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 07E880362350EDE5003471B4 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 2630; ORGANIZATIONNAME = "Momenta B.V."; TargetAttributes = { 07E8803D2350EDE5003471B4 = { CreatedOnToolsVersion = 11.1; }; }; }; buildConfigurationList = 07E880392350EDE5003471B4 /* Build configuration list for PBXProject "TheMessage" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 07E880352350EDE5003471B4; packageReferences = ( 07E880652350EF64003471B4 /* XCLocalSwiftPackageReference "../.." */, ); productRefGroup = 07E8803F2350EDE5003471B4 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 07E8803D2350EDE5003471B4 /* TheMessage */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 07E8803C2350EDE5003471B4 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 07E8804B2350EDE6003471B4 /* Preview Assets.xcassets in Resources */, 07E880482350EDE6003471B4 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 07E8803A2350EDE5003471B4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 07E880632350EF64003471B4 /* TheMessageApp.swift in Sources */, 07E880642350EF64003471B4 /* MessageStore.swift in Sources */, 07E880462350EDE5003471B4 /* ContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ 07E880502350EDE6003471B4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 07E880512350EDE6003471B4 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; 07E880532350EDE6003471B4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = TheMessage/TheMessage.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"TheMessage/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.mentalfaculty.themessage; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; 07E880542350EDE6003471B4 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = TheMessage/TheMessage.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"TheMessage/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.mentalfaculty.themessage; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 07E880392350EDE5003471B4 /* Build configuration list for PBXProject "TheMessage" */ = { isa = XCConfigurationList; buildConfigurations = ( 07E880502350EDE6003471B4 /* Debug */, 07E880512350EDE6003471B4 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 07E880522350EDE6003471B4 /* Build configuration list for PBXNativeTarget "TheMessage" */ = { isa = XCConfigurationList; buildConfigurations = ( 07E880532350EDE6003471B4 /* Debug */, 07E880542350EDE6003471B4 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ 07E880652350EF64003471B4 /* XCLocalSwiftPackageReference "../.." */ = { isa = XCLocalSwiftPackageReference; relativePath = ../..; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 07E8805D2350EF64003471B4 /* LLVS */ = { isa = XCSwiftPackageProductDependency; package = 07E880652350EF64003471B4 /* XCLocalSwiftPackageReference "../.." */; productName = LLVS; }; 07E8805F2350EF64003471B4 /* LLVSCloudKit */ = { isa = XCSwiftPackageProductDependency; package = 07E880652350EF64003471B4 /* XCLocalSwiftPackageReference "../.." */; productName = LLVSCloudKit; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 07E880362350EDE5003471B4 /* Project object */; } ================================================ FILE: Samples/TheMessage/TheMessage.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: Samples/TheMessage/TheMessage.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: Sources/LLVS/Core/Exchange.swift ================================================ // // Exchange.swift // LLVS // // Created by Drew McCormack on 25/02/2019. // import Foundation enum ExchangeError: Swift.Error { case remoteVersionsWithUnknownPredecessors case missingVersion case unknown(error: Swift.Error) } public typealias VersionChanges = (version: Version, valueChanges: [Value.Change]) public protocol SnapshotExchange { func retrieveSnapshotManifest() async throws -> SnapshotManifest? func retrieveSnapshotChunk(index: Int) async throws -> Data func sendSnapshot(manifest: SnapshotManifest, chunkProvider: @escaping @Sendable (Int) throws -> Data) async throws } public protocol Exchange: AnyObject { var newVersionsAvailable: AsyncStream { get } var store: Store { get } var restorationState: Data? { get set } func retrieve() async throws -> [Version.ID] func prepareToRetrieve() async throws func retrieveAllVersionIdentifiers() async throws -> [Version.ID] func retrieveVersions(identifiedBy versionIds: [Version.ID]) async throws -> [Version] func retrieveValueChanges(forVersionsIdentifiedBy versionIds: [Version.ID]) async throws -> [Version.ID: [Value.Change]] func send() async throws -> [Version.ID] func prepareToSend() async throws func send(versionChanges: [VersionChanges]) async throws } // MARK:- Retrieving public extension Exchange { func retrieve() async throws -> [Version.ID] { log.trace("Retrieving") try await self.prepareToRetrieve() let remoteIds = try await self.retrieveAllVersionIdentifiers() let toRetrieveIds = self.versionIdsMissingFromHistory(forRemoteIdentifiers: remoteIds) log.verbose("Version identifiers to retrieve: \(toRetrieveIds.idStrings)") let remoteVersions = try await self.retrieveVersions(identifiedBy: toRetrieveIds) log.verbose("Adding to history versions: \(remoteVersions.idStrings)") try await self.addToHistory(remoteVersions) log.trace("Retrieved") return remoteIds } private func versionIdsMissingFromHistory(forRemoteIdentifiers remoteIdentifiers: [Version.ID]) -> [Version.ID] { var toRetrieveIds: [Version.ID]! self.store.queryHistory { history in let storeVersionIds = Set(history.allVersionIdentifiers) let remoteVersionIds = Set(remoteIdentifiers) toRetrieveIds = Array(remoteVersionIds.subtracting(storeVersionIds)) } return toRetrieveIds } private func addToHistory(_ versions: [Version]) async throws { let versionsByIdentifier = versions.reduce(into: [:]) { result, version in result[version.id] = version } let sortedVersions = versions.sorted { $0.timestamp < $1.timestamp } func batchSizeCostEvaluator(index: Int) -> Float { let batchDataSizeLimit = 5000000 // 5MB let version = sortedVersions[index] return Float(version.valueDataSize ?? 100000) / Float(batchDataSizeLimit) } let dynamicBatcher = DynamicTaskBatcher(numberOfTasks: sortedVersions.count, taskCostEvaluator: batchSizeCostEvaluator) { range in let batchVersions = Array(sortedVersions[range]) let valueChangesByVersionIdentifier: [Version.ID: [Value.Change]] do { valueChangesByVersionIdentifier = try await self.retrieveValueChanges(forVersionsIdentifiedBy: batchVersions.ids) } catch { log.error("Failed adding to history: \(error)") return .definitive(.failure(error)) } let valueChangesByVersionID: [Version.ID: [Value.Change]] = valueChangesByVersionIdentifier.reduce(into: [:]) { result, keyValue in var version = versionsByIdentifier[keyValue.key]! if version.valueDataSize == nil { version.valueDataSize = keyValue.value.valueDataSize } result[version.id] = keyValue.value } do { try self.addToHistorySync(sortedVersions: batchVersions, valueChangesByVersionID: valueChangesByVersionID) return .definitive(.success(())) } catch let error as ExchangeError where error.isUnknownPredecessors { return .growBatchAndReexecute } catch { return .definitive(.failure(error)) } } try await dynamicBatcher.start() } /// Synchronously add sorted versions to history, iterating until all appendable versions are consumed. private func addToHistorySync(sortedVersions: [Version], valueChangesByVersionID: [Version.ID: [Value.Change]]) throws { var remaining = sortedVersions while !remaining.isEmpty { guard let version = appendableVersion(from: remaining) else { log.error("Failed to add to history due to missing predecessors") throw ExchangeError.remoteVersionsWithUnknownPredecessors } let valueChanges = valueChangesByVersionID[version.id]! log.trace("Adding version to store: \(version.id.rawValue)") log.verbose("Value changes for \(version.id.rawValue): \(valueChanges)") do { try self.store.addVersion(version, storing: valueChanges) } catch Store.Error.attemptToAddExistingVersion { log.error("Failed adding to history because version already exists. Ignoring error") } remaining = remaining.filter { $0.id != version.id } } } private func appendableVersion(from versions: [Version]) -> Version? { return versions.first { v in return store.historyIncludesVersions(identifiedBy: v.predecessors?.ids ?? []) } } } // MARK:- Sending public extension Exchange { func send() async throws -> [Version.ID] { try await self.prepareToSend() let remoteIds = try await self.retrieveAllVersionIdentifiers() let toSendIds = self.versionIdsMissingRemotely(forRemoteIdentifiers: remoteIds) func batchSizeCostEvaluator(index: Int) -> Float { let batchDataSizeLimit: Int64 = 5000000 // 5MB let defaultDataSize: Int64 = 100000 // 100KB if let version = try? self.store.version(identifiedBy: toSendIds[index]) { return Float(version.valueDataSize ?? defaultDataSize) / Float(batchDataSizeLimit) } else { return Float(defaultDataSize) / Float(batchDataSizeLimit) } } let taskBatcher = DynamicTaskBatcher(numberOfTasks: toSendIds.count, taskCostEvaluator: batchSizeCostEvaluator) { range in do { let versionChanges: [VersionChanges] = try toSendIds[range].map { versionId in guard let version = try self.store.version(identifiedBy: versionId) else { throw ExchangeError.missingVersion } let changes = try self.store.valueChanges(madeInVersionIdentifiedBy: versionId) return (version, changes) } guard !versionChanges.isEmpty else { return .definitive(.success(())) } try await self.send(versionChanges: versionChanges) return .definitive(.success(())) } catch { return .definitive(.failure(error)) } } try await taskBatcher.start() return toSendIds } private func versionIdsMissingRemotely(forRemoteIdentifiers remoteIdentifiers: [Version.ID]) -> [Version.ID] { var toSendIds: [Version.ID]! self.store.queryHistory { history in let storeVersionIds = Set(history.allVersionIdentifiers) let remoteVersionIds = Set(remoteIdentifiers) toSendIds = Array(storeVersionIds.subtracting(remoteVersionIds)) } return toSendIds } } // MARK:- ExchangeError helper fileprivate extension ExchangeError { var isUnknownPredecessors: Bool { if case .remoteVersionsWithUnknownPredecessors = self { return true } return false } } ================================================ FILE: Sources/LLVS/Core/FileZone.swift ================================================ // // FileZone.swift // LLVS // // Created by Drew McCormack on 14/05/2019. // import Foundation public class FileStorage: Storage, SnapshotCapable { private let fileExtension = "json" public init() {} public func makeMapZone(for type: MapType, in store: Store) -> Zone { switch type { case .valuesByVersion: return FileZone(rootDirectory: store.valuesMapDirectoryURL, fileExtension: fileExtension) case .userDefined: fatalError("User defined maps not yet supported") } } public func makeValuesZone(in store: Store) -> Zone { return FileZone(rootDirectory: store.valuesDirectoryURL, fileExtension: fileExtension) } } internal final class FileZone: Zone { let rootDirectory: URL let fileExtension: String private let uncachableDataSizeLimit = 10000 // 10KB private let cache: Cache = .init() fileprivate let fileManager = FileManager() init(rootDirectory: URL, fileExtension: String) { self.rootDirectory = rootDirectory.resolvingSymlinksInPath() try? fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true, attributes: nil) self.fileExtension = fileExtension } private func cacheIfNeeded(_ data: Data, for reference: ZoneReference) { if data.count < uncachableDataSizeLimit { cache.setValue(data, for: reference) } } internal func store(_ data: Data, for reference: ZoneReference) throws { let (dir, file) = try fileSystemLocation(for: reference) try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) let compressed = DataCompression.compress(data) try compressed.write(to: file) cacheIfNeeded(data, for: reference) } internal func data(for reference: ZoneReference) throws -> Data? { if let data = cache.value(for: reference) { return data } let (_, file) = try fileSystemLocation(for: reference) guard let raw = try? Data(contentsOf: file) else { return nil } let data = DataCompression.decompressIfNeeded(raw) cacheIfNeeded(data, for: reference) return data } func fileSystemLocation(for reference: ZoneReference) throws -> (directoryURL: URL, fileURL: URL) { let safeKey = reference.key.replacingOccurrences(of: "/", with: "LLVSSLASH").replacingOccurrences(of: ":", with: "LLVSCOLON") let valueDirectoryURL = rootDirectory.appendingSplitPathComponent(safeKey) let versionName = reference.version.rawValue + "." + fileExtension let fileURL = valueDirectoryURL.appendingSplitPathComponent(versionName, prefixLength: 1) let directoryURL = fileURL.deletingLastPathComponent() return (directoryURL: directoryURL, fileURL: fileURL) } internal func versionIds(for key: String) throws -> [Version.ID] { let valueDirectoryURL = rootDirectory.appendingSplitPathComponent(key) let valueDirLength = valueDirectoryURL.path.count let enumerator = fileManager.enumerator(at: valueDirectoryURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])! var versions: [Version.ID] = [] let slash = Character("/") for any in enumerator { var isDirectory: ObjCBool = true guard let url = any as? URL else { continue } guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && !isDirectory.boolValue else { continue } let path = url.resolvingSymlinksInPath().deletingPathExtension().path let index = path.index(path.startIndex, offsetBy: Int(valueDirLength)) let version = String(path[index...]).filter { $0 != slash } versions.append(Version.ID(version)) } return versions } } ================================================ FILE: Sources/LLVS/Core/FolderBasedExchange.swift ================================================ // // FolderBasedExchange.swift // LLVS // // Created by Drew McCormack on 01/03/2026. // import Foundation public enum FolderBasedExchangeError: Error { case versionFileInvalid case fileNotFound(String) case foldersNotInitialized } public protocol FolderBasedExchange: Exchange { associatedtype FileID associatedtype FolderID var versionsFolderID: FolderID? { get } var changesFolderID: FolderID? { get } func notifyNewVersionsAvailable() func listFiles(inFolder folder: FolderID) async throws -> [String: FileID] func downloadData(forFile fileID: FileID) async throws -> Data func uploadData(_ data: Data, named name: String, toFolder folder: FolderID) async throws } public extension FolderBasedExchange { func retrieveAllVersionIdentifiers() async throws -> [Version.ID] { guard let folderID = versionsFolderID else { return [] } let fileMap = try await listFiles(inFolder: folderID) return fileMap.map { Version.ID($0.key) } } func retrieveVersions(identifiedBy versionIds: [Version.ID]) async throws -> [Version] { guard !versionIds.isEmpty else { return [] } guard let folderID = versionsFolderID else { return [] } let fileMap = try await listFiles(inFolder: folderID) var versions: [Version] = [] for versionId in versionIds { guard let fileID = fileMap[versionId.rawValue] else { throw FolderBasedExchangeError.fileNotFound(versionId.rawValue) } let data = try await downloadData(forFile: fileID) if let version = try JSONDecoder().decode([String: Version].self, from: data)["version"] { versions.append(version) } else { throw FolderBasedExchangeError.versionFileInvalid } } return versions } func retrieveValueChanges(forVersionsIdentifiedBy versionIds: [Version.ID]) async throws -> [Version.ID: [Value.Change]] { guard !versionIds.isEmpty else { return [:] } guard let folderID = changesFolderID else { return [:] } let fileMap = try await listFiles(inFolder: folderID) var changesByVersion: [Version.ID: [Value.Change]] = [:] for versionId in versionIds { guard let fileID = fileMap[versionId.rawValue] else { throw FolderBasedExchangeError.fileNotFound(versionId.rawValue) } let data = try await downloadData(forFile: fileID) let changes = try JSONDecoder().decode([Value.Change].self, from: data) changesByVersion[versionId] = changes } return changesByVersion } func send(versionChanges: [VersionChanges]) async throws { guard !versionChanges.isEmpty else { return } guard let versionsFolderID = versionsFolderID, let changesFolderID = changesFolderID else { throw FolderBasedExchangeError.foldersNotInitialized } for (version, valueChanges) in versionChanges { let changesData = try JSONEncoder().encode(valueChanges) let versionData = try JSONEncoder().encode(["version": version]) try await uploadData(changesData, named: version.id.rawValue, toFolder: changesFolderID) try await uploadData(versionData, named: version.id.rawValue, toFolder: versionsFolderID) } notifyNewVersionsAvailable() } } ================================================ FILE: Sources/LLVS/Core/History.swift ================================================ // // History.swift // LLVS // // Created by Drew McCormack on 11/11/2018. // import Foundation public class History { public enum Error: Swift.Error { case attemptToAddPreexistingVersion(id: String) case nonExistentVersionEncountered(identifier: String) } private var versionsByIdentifier: [Version.ID:Version] = [:] private var referencedVersionIdentifiers: Set = [] // Any version that is a predecessor public private(set) var headIdentifiers: Set = [] // Versions that are not predecessors of other versions public var allVersionIdentifiers: [Version.ID] { return Array(versionsByIdentifier.keys) } public var mostRecentHead: Version? { let maxId = headIdentifiers.max { (vId1, vId2) -> Bool in let v1 = version(identifiedBy: vId1)! let v2 = version(identifiedBy: vId2)! return v1.timestamp < v2.timestamp } return maxId.flatMap { version(identifiedBy: $0) } } public func version(identifiedBy identifier: Version.ID) -> Version? { return versionsByIdentifier[identifier] } internal func version(prevailingFromCandidates candidates: [Version.ID], at versionId: Version.ID) -> Version? { if let candidate = candidates.first(where: { $0 == versionId }) { return version(identifiedBy: candidate) } var ancestors: Set = [versionId] for v in self { // See if v is in our ancestry. If so, extend ancestry. if ancestors.contains(v.id) { ancestors.formUnion(v.predecessors?.ids ?? []) ancestors.remove(v.id) } if let candidate = candidates.first(where: { ancestors.contains($0) }) { return version(identifiedBy: candidate) } } return nil } internal func isAncestralLine(from ancestor: Version.ID, to descendant: Version.ID) -> Bool { return nil != version(prevailingFromCandidates: [ancestor], at: descendant) } /// If updatingPredecessorVersions is true, the successors of other versions may be updated. /// Use this when adding a new head when storing. /// Pass in false if more control is needed over setting the successors, such as /// when loading them to setup the History. In that case, we only want to set them when all versions /// have been loaded. internal func add(_ version: Version, updatingPredecessorVersions: Bool) throws { guard versionsByIdentifier[version.id] == nil else { throw Error.attemptToAddPreexistingVersion(id: version.id.rawValue) } versionsByIdentifier[version.id] = version if updatingPredecessorVersions { try updateSuccessors(inPredecessorsOf: version) } if !referencedVersionIdentifiers.contains(version.id) { headIdentifiers.insert(version.id) } } internal func updateSuccessors(inPredecessorsOf version: Version) throws { for predecessorIdentifier in version.predecessors?.ids ?? [] { guard let predecessor = self.version(identifiedBy: predecessorIdentifier) else { throw Error.nonExistentVersionEncountered(identifier: predecessorIdentifier.rawValue) } referencedVersionIdentifiers.insert(predecessorIdentifier) headIdentifiers.remove(predecessorIdentifier) var newPredecessor = predecessor let newSuccessorIdentifiers = predecessor.successors.ids.union([version.id]) newPredecessor.successors = Version.Successors(ids: newSuccessorIdentifiers) versionsByIdentifier[newPredecessor.id] = newPredecessor } } /// Finds the greatest common ancestor of all the given version IDs by pairwise reduction. internal func greatestCommonAncestor(ofAll versionIds: Set) throws -> Version.ID? { guard !versionIds.isEmpty else { return nil } var ids = Array(versionIds) var result = ids.removeFirst() for id in ids { guard let gca = try greatestCommonAncestor(ofVersionsIdentifiedBy: (result, id)) else { return nil } result = gca } return result } public func greatestCommonAncestor(ofVersionsIdentifiedBy ids: (Version.ID, Version.ID)) throws -> Version.ID? { // Find all ancestors of first Version. Determine how many generations back each Version is. // We take the shortest path to any given Version, ie, the minimum of possible paths. var generationById = [Version.ID:Int]() var firstFront: Set = [ids.0] func propagateFront(front: inout Set) throws { var newFront = Set() for identifier in front { guard let frontVersion = self.version(identifiedBy: identifier) else { throw Error.nonExistentVersionEncountered(identifier: identifier.rawValue) } newFront.formUnion(frontVersion.predecessors?.ids ?? []) } front = newFront } var generation = 0 while firstFront.count > 0 { firstFront.forEach { generationById[$0] = Swift.min(generationById[$0] ?? Int.max, generation) } try propagateFront(front: &firstFront) generation += 1 } // Now go through ancestors of second version until we find the first in common with the first ancestors var secondFront: Set = [ids.1] let ancestorsOfFirst = Set(generationById.keys) while secondFront.count > 0 { let common = ancestorsOfFirst.intersection(secondFront) let sorted = common.sorted { generationById[$0]! < generationById[$1]! } if let mostRecentCommon = sorted.first { return mostRecentCommon } try propagateFront(front: &secondFront) } return nil } } extension History: Sequence { /// Enumerates history in a topological sorted order. /// Note that there are many possible orders that satisfy this. /// Most recent versions are ordered first (ie heads). /// Return false from block to stop. /// Uses Kahn algorithm to generate the order. https://en.wikipedia.org/wiki/Topological_sorting public struct TopologicalIterator: IteratorProtocol { public typealias Element = Version public let history: History private var front: Set private var referenceCountByIdentifier: [Version.ID:Int] = [:] init(toIterate history: History) { self.history = history let headVersions = history.headIdentifiers.map { history.version(identifiedBy: $0)! } self.front = Set(headVersions) } public mutating func next() -> Version? { guard let next = front.first(where: { version in let refCount = self.referenceCountByIdentifier[version.id] ?? 0 let successorCount = version.successors.ids.count return refCount == successorCount }) else { return nil } for predecessorIdentifier in next.predecessors?.ids ?? [] { let predecessor = history.version(identifiedBy: predecessorIdentifier)! referenceCountByIdentifier[predecessor.id] = (referenceCountByIdentifier[predecessor.id] ?? 0) + 1 front.insert(predecessor) } front.remove(next) return next } } public func makeIterator() -> History.TopologicalIterator { return Iterator(toIterate: self) } } ================================================ FILE: Sources/LLVS/Core/Map.swift ================================================ // // Map.swift // LLVS // // Created by Drew McCormack on 30/11/2018. // import Foundation final class Map { enum Error: Swift.Error { case encodingFailure(String) case unexpectedNodeContent case missingNode case missingVersionRoot } let zone: Zone private let nodeCache: Cache = .init() private let rootKey = "__llvs_root" init(zone: Zone) { self.zone = zone } func addVersion(_ version: Version.ID, basedOn baseVersion: Version.ID?, applying deltas: [Delta]) throws { try autoreleasepool { let encoder = JSONEncoder() var rootNode: Node let rootRef = ZoneReference(key: rootKey, version: version) if let baseVersion = baseVersion { let oldRootRef = ZoneReference(key: rootKey, version: baseVersion) guard let oldRoot = try node(for: oldRootRef) else { throw Error.missingNode } rootNode = oldRoot } else { rootNode = Node(reference: rootRef, children: .nodes([])) } rootNode.reference.version = version guard case let .nodes(rootChildRefs) = rootNode.children else { throw Error.unexpectedNodeContent } var subNodesByKey: [Key:Node] = [:] for delta in deltas { let key = delta.key let subNodeKey = Key(String(key.keyString.prefix(2))) let subNodeRef = ZoneReference(key: subNodeKey.keyString, version: version) var subNode: Node if let n = subNodesByKey[subNodeKey] { subNode = n } else if let existingSubNodeRef = rootChildRefs.first(where: { $0.key == subNodeKey.keyString }) { guard let existingSubNode = try node(for: existingSubNodeRef) else { throw Error.missingNode } subNode = existingSubNode subNode.reference = subNodeRef } else { subNode = Node(reference: subNodeRef, children: .values([])) } guard case let .values(keyValuePairs) = subNode.children else { throw Error.unexpectedNodeContent } let valueRefs = keyValuePairs.filter({ $0.key == key }).map({ $0.valueReference }) var valueRefsByIdentifier: [Value.ID:Value.Reference] = Dictionary(uniqueKeysWithValues: valueRefs.map({ ($0.valueId, $0) }) ) for valueRef in delta.addedValueReferences { valueRefsByIdentifier[valueRef.valueId] = valueRef } for valueId in delta.removedValueIdentifiers { valueRefsByIdentifier[valueId] = nil } let newValueRefs = Array(valueRefsByIdentifier.values) var newPairs = keyValuePairs.filter { $0.key != key } newPairs += newValueRefs.map { KeyValuePair(key: key, valueReference: $0) } subNode.children = .values(newPairs) subNodesByKey[subNodeKey] = subNode } // Update and save subnodes and rootnode var rootRefsByIdentifier: [String:ZoneReference] = Dictionary(uniqueKeysWithValues: rootChildRefs.map({ ($0.key, $0) }) ) for subNode in subNodesByKey.values { let key = subNode.reference.key let data = try encoder.encode(subNode) try zone.store(data, for: subNode.reference) rootRefsByIdentifier[key] = subNode.reference } rootNode.children = .nodes(Array(rootRefsByIdentifier.values)) let data = try encoder.encode(rootNode) try zone.store(data, for: rootNode.reference) } } func purgeCache() { nodeCache.purgeAllValues() } /// Returns zone references for the root node and all subnodes for a given version. /// Used during compaction cleanup to know what Map data to delete. func zoneReferences(forVersionIdentifiedBy versionId: Version.ID) throws -> [ZoneReference] { let rootRef = ZoneReference(key: rootKey, version: versionId) guard let rootNode = try node(for: rootRef) else { return [] } var refs: [ZoneReference] = [rootRef] if case let .nodes(subNodeRefs) = rootNode.children { refs.append(contentsOf: subNodeRefs) } return refs } func differences(between firstVersion: Version.ID, and secondVersion: Version.ID, withCommonAncestor commonAncestor: Version.ID?) throws -> [Diff] { let originRef = commonAncestor.flatMap { ZoneReference(key: rootKey, version: $0) } let rootRef1 = ZoneReference(key: rootKey, version: firstVersion) let rootRef2 = ZoneReference(key: rootKey, version: secondVersion) let originNode = try originRef.flatMap { try node(for: $0) } guard let rootNode1 = try node(for: rootRef1), let rootNode2 = try node(for: rootRef2) else { throw Error.missingVersionRoot } let nodesOrigin: [ZoneReference]? if case let .nodes(n)? = originNode?.children { nodesOrigin = n } else { nodesOrigin = nil } guard case let .nodes(subNodes1) = rootNode1.children, case let .nodes(subNodes2) = rootNode2.children else { throw Error.unexpectedNodeContent } let refOriginByKey: [String:ZoneReference]? = nodesOrigin.flatMap { refs in .init(uniqueKeysWithValues: refs.map({ ($0.key, $0) })) } let subNodeRefs1ByKey: [String:ZoneReference] = .init(uniqueKeysWithValues: subNodes1.map({ ($0.key, $0) })) let subNodeRefs2ByKey: [String:ZoneReference] = .init(uniqueKeysWithValues: subNodes2.map({ ($0.key, $0) })) var allSubNodeKeys = Set(subNodeRefs1ByKey.keys).union(subNodeRefs2ByKey.keys) if let r = refOriginByKey { allSubNodeKeys.formUnion(r.keys) } // Batch prefetch only subnodes that will actually be compared var refsToFetch: [ZoneReference] = [] for subNodeKey in allSubNodeKeys { let r1 = subNodeRefs1ByKey[subNodeKey] let r2 = subNodeRefs2ByKey[subNodeKey] if r1 == r2 { continue } if let r = r1 { refsToFetch.append(r) } if let r = r2 { refsToFetch.append(r) } if let r = refOriginByKey?[subNodeKey] { refsToFetch.append(r) } } try prefetchNodes(for: refsToFetch) var diffs: [Diff] = [] for subNodeKey in allSubNodeKeys { func appendDiffs(forIdentifiers ids: [Value.ID], fork: Value.Fork) throws { for id in ids { let diff = Diff(key: .init(subNodeKey), valueId: id, valueFork: fork) diffs.append(diff) } } func appendDiffs(forSubNode subNodeRef: ZoneReference, fork: Value.Fork) throws { let refs = try valueReferences(forRootSubNode: subNodeRef) try appendDiffs(forIdentifiers: refs.map({ $0.valueId }), fork: fork) } func appendDiffs(forOriginNode originNode: ZoneReference, onlyBranchNode branchNode: ZoneReference, branch: Value.Fork.Branch) throws { let vo = try valueReferences(forRootSubNode: originNode) let vb = try valueReferences(forRootSubNode: branchNode) let refOById: [Value.ID:Value.Reference] = .init(uniqueKeysWithValues: vo.map({ ($0.valueId, $0) })) let refBById: [Value.ID:Value.Reference] = .init(uniqueKeysWithValues: vb.map({ ($0.valueId, $0) })) let allIds = Set(refOById.keys).union(refBById.keys) for valueId in allIds { let refO = refOById[valueId] let refB = refBById[valueId] switch (refO, refB) { case let (ro?, rb?): if ro != rb { try appendDiffs(forIdentifiers: [valueId], fork: .removedAndUpdated(removedOn: branch.opposite)) } else { try appendDiffs(forIdentifiers: [valueId], fork: .removed(branch.opposite)) } case (_?, nil): try appendDiffs(forIdentifiers: [valueId], fork: .twiceRemoved) case (nil, _?): try appendDiffs(forIdentifiers: [valueId], fork: .inserted(branch)) case (nil, nil): fatalError() } } } let ref1 = subNodeRefs1ByKey[subNodeKey] let ref2 = subNodeRefs2ByKey[subNodeKey] // Skip bucket entirely if both branches reference the same subnode if ref1 == ref2 { continue } let origin = refOriginByKey?[subNodeKey] switch (origin, ref1, ref2) { case let (o?, r1?, r2?): let vo = try valueReferences(forRootSubNode: o) let v1 = try valueReferences(forRootSubNode: r1) let v2 = try valueReferences(forRootSubNode: r2) let refOById: [Value.ID:Value.Reference] = .init(uniqueKeysWithValues: vo.map({ ($0.valueId, $0) })) let ref1ById: [Value.ID:Value.Reference] = .init(uniqueKeysWithValues: v1.map({ ($0.valueId, $0) })) let ref2ById: [Value.ID:Value.Reference] = .init(uniqueKeysWithValues: v2.map({ ($0.valueId, $0) })) let allIds = Set(refOById.keys).union(ref1ById.keys).union(ref2ById.keys) for valueId in allIds { let refO = refOById[valueId] let ref1 = ref1ById[valueId] let ref2 = ref2ById[valueId] switch (refO, ref1, ref2) { case let (ro?, r1?, r2?): if ro == r1, ro != r2 { try appendDiffs(forIdentifiers: [valueId], fork: .updated(.second)) } else if ro != r1, ro == r2 { try appendDiffs(forIdentifiers: [valueId], fork: .updated(.first)) } else if ro != r1, ro != r2 { try appendDiffs(forIdentifiers: [valueId], fork: .twiceUpdated) } case let (ro?, r1?, nil): if ro != r1 { try appendDiffs(forIdentifiers: [valueId], fork: .removedAndUpdated(removedOn: .second)) } else { try appendDiffs(forIdentifiers: [valueId], fork: .removed(.second)) } case let (ro?, nil, r2?): if ro != r2 { try appendDiffs(forIdentifiers: [valueId], fork: .removedAndUpdated(removedOn: .first)) } else { try appendDiffs(forIdentifiers: [valueId], fork: .removed(.first)) } case (nil, _?, _?): try appendDiffs(forIdentifiers: [valueId], fork: .twiceInserted) case (nil, nil, _?): try appendDiffs(forIdentifiers: [valueId], fork: .inserted(.second)) case (nil, _?, nil): try appendDiffs(forIdentifiers: [valueId], fork: .inserted(.first)) case (_?, nil, nil): try appendDiffs(forIdentifiers: [valueId], fork: .twiceRemoved) case (nil, nil, nil): fatalError() } } case let (o?, r1?, nil): try appendDiffs(forOriginNode: o, onlyBranchNode: r1, branch: .first) case let (o?, nil, r2?): try appendDiffs(forOriginNode: o, onlyBranchNode: r2, branch: .second) case let (nil, r1?, r2?): let v1 = try valueReferences(forRootSubNode: r1) let v2 = try valueReferences(forRootSubNode: r2) let ref1ById: [Value.ID:Value.Reference] = .init(uniqueKeysWithValues: v1.map({ ($0.valueId, $0) })) let ref2ById: [Value.ID:Value.Reference] = .init(uniqueKeysWithValues: v2.map({ ($0.valueId, $0) })) let allIds = Set(ref1ById.keys).union(ref2ById.keys) for valueId in allIds { let ref1 = ref1ById[valueId] let ref2 = ref2ById[valueId] switch (ref1, ref2) { case (_?, _?): try appendDiffs(forIdentifiers: [valueId], fork: .twiceInserted) case (_?, nil): try appendDiffs(forIdentifiers: [valueId], fork: .inserted(.first)) case (nil, _?): try appendDiffs(forIdentifiers: [valueId], fork: .inserted(.second)) case (nil, nil): fatalError() } } case let (nil, r1?, nil): try appendDiffs(forSubNode: r1, fork: .inserted(.first)) case let (nil, nil, r2?): try appendDiffs(forSubNode: r2, fork: .inserted(.second)) case let (o?, nil, nil): try appendDiffs(forSubNode: o, fork: .twiceRemoved) case (nil, nil, nil): fatalError() } } return diffs } func enumerateValueReferences(forVersionIdentifiedBy versionId: Version.ID, executingForEach block: (Value.Reference) throws -> Void) throws { let rootRef = ZoneReference(key: rootKey, version: versionId) guard let rootNode = try node(for: rootRef) else { throw Error.missingVersionRoot } guard case let .nodes(subNodeRefs) = rootNode.children else { throw Error.missingNode } try prefetchNodes(for: subNodeRefs) for subNodeRef in subNodeRefs { guard let subNode = try node(for: subNodeRef) else { throw Error.missingNode } guard case let .values(keyValuePairs) = subNode.children else { throw Error.unexpectedNodeContent } for keyValuePair in keyValuePairs { try block(keyValuePair.valueReference) } } } func valueReferences(matching key: Map.Key, at version: Version.ID) throws -> [Value.Reference] { let rootRef = ZoneReference(key: rootKey, version: version) guard let rootNode = try node(for: rootRef) else { throw Error.missingVersionRoot } guard case let .nodes(subNodeRefs) = rootNode.children else { throw Error.missingNode } let subNodeKey = String(key.keyString.prefix(2)) guard let subNodeRef = subNodeRefs.first(where: { $0.key == subNodeKey }) else { return [] } guard let subNode = try node(for: subNodeRef) else { throw Error.missingNode } guard case let .values(keyValuePairs) = subNode.children else { throw Error.unexpectedNodeContent } return keyValuePairs.filter({ $0.key == key }).map({ $0.valueReference }) } fileprivate func node(for key: String, version: Version.ID) throws -> Node? { let ref = ZoneReference(key: key, version: version) return try node(for: ref) } fileprivate func node(for reference: ZoneReference) throws -> Node? { try autoreleasepool { if let node = nodeCache.value(for: reference) { return node } else if let data = try zone.data(for: reference) { let node = try JSONDecoder().decode(Node.self, from: data) nodeCache.setValue(node, for: reference) return node } else { return nil } } } private func prefetchNodes(for references: [ZoneReference]) throws { let uncachedRefs = references.filter { nodeCache.value(for: $0) == nil } guard !uncachedRefs.isEmpty else { return } let dataArray = try zone.data(for: uncachedRefs) let decoder = JSONDecoder() for (ref, data) in zip(uncachedRefs, dataArray) { if let data = data, let node = try? decoder.decode(Node.self, from: data) { nodeCache.setValue(node, for: ref) } } } private func valueReferences(forRootSubNode subNodeRef: ZoneReference) throws -> [Value.Reference] { guard let subNode = try node(for: subNodeRef) else { throw Error.missingNode } guard case let .values(keyValuePairs) = subNode.children else { throw Error.unexpectedNodeContent } return keyValuePairs.map({ $0.valueReference }) } } // MARK:- Subtypes extension Map { struct Key: Codable, Hashable { var keyString: String init(_ keyString: String = UUID().uuidString) { self.keyString = keyString } } struct Diff { var key: Key var valueId: Value.ID var valueFork: Value.Fork } struct KeyValuePair: Codable, Hashable { var key: Key var valueReference: Value.Reference } struct Delta { var key: Key var addedValueReferences: [Value.Reference] = [] var removedValueIdentifiers: [Value.ID] = [] init(key: Key) { self.key = key } } struct Node: Codable, Hashable { var reference: ZoneReference var children: Children } enum Children: Codable, Hashable { case values([KeyValuePair]) case nodes([ZoneReference]) enum Key: CodingKey { case values case nodes } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: Key.self) if let values = try? container.decode([KeyValuePair].self, forKey: .values) { self = .values(values) } else if let nodes = try? container.decode([ZoneReference].self, forKey: .nodes) { self = .nodes(nodes) } else { throw Error.encodingFailure("No valid references found in decoder") } } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: Key.self) switch self { case let .values(values): try container.encode(values, forKey: .values) case let .nodes(nodes): try container.encode(nodes, forKey: .nodes) } } } } ================================================ FILE: Sources/LLVS/Core/Merge.swift ================================================ // // Merge.swift // LLVS // // Created by Drew McCormack on 19/12/2018. // import Foundation public struct Merge { public var commonAncestor: Version? public var versions: (first: Version, second: Version) public var forksByValueIdentifier: [Value.ID:Value.Fork] = [:] public init(versions: (first: Version, second: Version), commonAncestor: Version?) { self.commonAncestor = commonAncestor self.versions = versions } } public protocol MergeArbiter { func changes(toResolve merge: Merge, in store: Store) throws -> [Value.Change] } /// When conflicting, always favor the branch with the most recent version. public class MostRecentBranchFavoringArbiter: MergeArbiter { public init() {} public func changes(toResolve merge: Merge, in store: Store) throws -> [Value.Change] { let v = merge.versions let favoredBranch: Value.Fork.Branch = v.first.timestamp >= v.second.timestamp ? .first : .second let favoredVersion = favoredBranch == .first ? v.first : v.second var changes: [Value.Change] = [] for (valueId, fork) in merge.forksByValueIdentifier { switch fork { case let .removedAndUpdated(removeBranch): if removeBranch == favoredBranch { changes.append(.preserveRemoval(valueId)) } else { let value = try store.value(id: valueId, at: favoredVersion.id)! changes.append(.preserve(value.reference!)) } case .twiceInserted, .twiceUpdated: let value = try store.value(id: valueId, at: favoredVersion.id)! changes.append(.preserve(value.reference!)) case .inserted, .removed, .updated, .twiceRemoved: break } } return changes } } /// Favors the most recent change on a conflict by conflict basis. /// Will pick an update over a removal, regardless of recency. public class MostRecentChangeFavoringArbiter: MergeArbiter { public init() {} public 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 let .removedAndUpdated(removeBranch): let favoredVersion = removeBranch.opposite == .first ? v.first : v.second let value = try store.value(id: valueId, at: favoredVersion.id)! changes.append(.preserve(value.reference!)) case .twiceInserted, .twiceUpdated: let value1 = try store.value(id: valueId, at: v.first.id)! var version1: Version! store.queryHistory { version1 = $0.version(identifiedBy: value1.storedVersionId!) } let value2 = try store.value(id: valueId, at: v.second.id)! var version2: Version! store.queryHistory { version2 = $0.version(identifiedBy: value2.storedVersionId!) } if version1.timestamp >= version2.timestamp { changes.append(.preserve(value1.reference!)) } else { changes.append(.preserve(value2.reference!)) } case .inserted, .removed, .updated, .twiceRemoved: break } } return changes } } ================================================ FILE: Sources/LLVS/Core/Snapshot.swift ================================================ // // Snapshot.swift // LLVS // // Created by Drew McCormack on 09/02/2026. // import Foundation /// Metadata describing a snapshot stored in the cloud. public struct SnapshotManifest: Codable { public var snapshotId: String public var format: String public var createdAt: Date public var latestVersionId: Version.ID public var versionCount: Int public var chunkCount: Int public var totalSize: Int64 public init(snapshotId: String = UUID().uuidString, format: String, createdAt: Date = Date(), latestVersionId: Version.ID, versionCount: Int, chunkCount: Int, totalSize: Int64) { self.snapshotId = snapshotId self.format = format self.createdAt = createdAt self.latestVersionId = latestVersionId self.versionCount = versionCount self.chunkCount = chunkCount self.totalSize = totalSize } } /// Policy controlling automatic snapshot creation after sync. public struct SnapshotPolicy { public var enabled: Bool public var minimumInterval: TimeInterval public var minimumNewVersions: Int public init(enabled: Bool, minimumInterval: TimeInterval, minimumNewVersions: Int) { self.enabled = enabled self.minimumInterval = minimumInterval self.minimumNewVersions = minimumNewVersions } public static let auto = SnapshotPolicy( enabled: true, minimumInterval: 7*24*3600, minimumNewVersions: 20 ) public static let disabled = SnapshotPolicy( enabled: false, minimumInterval: 0, minimumNewVersions: 0 ) } ================================================ FILE: Sources/LLVS/Core/SnapshotCapable+ZIP.swift ================================================ // // SnapshotCapable+ZIP.swift // LLVS // // Created by Drew McCormack on 01/03/2026. // import Foundation import ZIPFoundation extension SnapshotCapable { public var snapshotFormat: String { "zip-v1" } public func writeSnapshotChunks(storeRootURL: URL, to directory: URL, maxChunkSize: Int) throws -> SnapshotManifest { let fm = FileManager() try fm.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) // Create zip of the entire store directory let zipURL = directory.appendingPathComponent("snapshot.zip") try fm.zipItem(at: storeRootURL, to: zipURL, shouldKeepParent: false, compressionMethod: .deflate) defer { try? fm.removeItem(at: zipURL) } // Get total compressed size let zipAttributes = try fm.attributesOfItem(atPath: zipURL.path) let totalSize = (zipAttributes[.size] as? Int64) ?? 0 // Split zip into chunks using FileHandle for streaming let readHandle = try FileHandle(forReadingFrom: zipURL) defer { try? readHandle.close() } var chunkIndex = 0 var bytesRemaining = totalSize while bytesRemaining > 0 { let bytesToRead = min(Int(bytesRemaining), maxChunkSize) let chunkData = readHandle.readData(ofLength: bytesToRead) if chunkData.isEmpty { break } let chunkFile = directory.appendingPathComponent(String(format: "chunk-%03d", chunkIndex)) try chunkData.write(to: chunkFile) chunkIndex += 1 bytesRemaining -= Int64(chunkData.count) } // Scan versions/ for manifest metadata let versionsDir = storeRootURL.appendingPathComponent("versions") var versionCount = 0 var latestVersionId = Version.ID("") var maxTimestamp: TimeInterval = 0 if let versionsEnum = fm.enumerator(at: versionsDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) { for case let fileURL as URL in versionsEnum { guard fileURL.pathExtension == "json" else { continue } var isDir: ObjCBool = false guard fm.fileExists(atPath: fileURL.path, isDirectory: &isDir), !isDir.boolValue else { continue } versionCount += 1 if let data = try? Data(contentsOf: fileURL), let version = try? JSONDecoder().decode(Version.self, from: data), version.timestamp > maxTimestamp { maxTimestamp = version.timestamp latestVersionId = version.id } } } return SnapshotManifest( format: snapshotFormat, latestVersionId: latestVersionId, versionCount: versionCount, chunkCount: chunkIndex, totalSize: totalSize ) } public func restoreFromSnapshotChunks(storeRootURL: URL, from directory: URL, manifest: SnapshotManifest) throws { let fm = FileManager() // Concatenate chunks into a zip file let zipURL = directory.appendingPathComponent("snapshot.zip") fm.createFile(atPath: zipURL.path, contents: nil) let writeHandle = try FileHandle(forWritingTo: zipURL) for i in 0.. Zone func makeMapZone(for type: MapType, in store: Store) throws -> Zone } /// Storage backends that can produce and consume chunked snapshots. public protocol SnapshotCapable { var snapshotFormat: String { get } func writeSnapshotChunks( storeRootURL: URL, to directory: URL, maxChunkSize: Int ) throws -> SnapshotManifest func restoreFromSnapshotChunks( storeRootURL: URL, from directory: URL, manifest: SnapshotManifest ) throws } ================================================ FILE: Sources/LLVS/Core/Store.swift ================================================ // // Store.swift // llvs // // Created by Drew McCormack on 31/10/2018. // import Foundation import Synchronization // MARK:- Branch public struct Branch: RawRepresentable { public let rawValue: String public init(rawValue: String) { self.rawValue = rawValue } public init(randomizedNameBasedOn base: String = "") { let separator = base.isEmpty ? "" : "_" self.rawValue = "\(base)\(separator)\(UUID().uuidString)" } } // MARK:- Store public final class Store { public enum Error: Swift.Error { case missingVersion case attemptToLocateUnversionedValue case attemptToStoreValueWithNoVersion case noCommonAncestor(firstVersion: Version.ID, secondVersion: Version.ID) case unresolvedConflict(valueId: Value.ID, valueFork: Value.Fork) case attemptToAddExistingVersion(Version.ID) case attemptToAddVersionWithNonexistingPredecessors(Version) } public let rootDirectoryURL: URL public let valuesDirectoryURL: URL public let versionsDirectoryURL: URL public let mapsDirectoryURL: URL public let valuesMapDirectoryURL: URL public let storage: Storage private lazy var valuesZone: Zone = { return try! storage.makeValuesZone(in: self) }() private let valuesMapName = "__llvs_values" private lazy var valuesMap: Map = { let valuesMapZone = try! self.storage.makeMapZone(for: .valuesByVersion, in: self) return Map(zone: valuesMapZone) }() private let history = Mutex(History()) fileprivate let fileManager = FileManager() public init(rootDirectoryURL: URL, storage: Storage = FileStorage()) throws { self.storage = storage self.rootDirectoryURL = rootDirectoryURL.resolvingSymlinksInPath() self.valuesDirectoryURL = rootDirectoryURL.appendingPathComponent("values") self.versionsDirectoryURL = rootDirectoryURL.appendingPathComponent("versions") self.mapsDirectoryURL = rootDirectoryURL.appendingPathComponent("maps") self.valuesMapDirectoryURL = self.mapsDirectoryURL.appendingPathComponent(valuesMapName) try? fileManager.createDirectory(at: self.rootDirectoryURL, withIntermediateDirectories: true, attributes: nil) try? fileManager.createDirectory(at: self.valuesDirectoryURL, withIntermediateDirectories: true, attributes: nil) try? fileManager.createDirectory(at: self.versionsDirectoryURL, withIntermediateDirectories: true, attributes: nil) try? fileManager.createDirectory(at: self.mapsDirectoryURL, withIntermediateDirectories: true, attributes: nil) try reloadHistory() } /// Call this to make sure all history is loaded. For example, if the store could have been /// changed by another process, calling this method will ensure the versions added by that process /// are loaded. public func reloadHistory() throws { try history.withLock { history in var newVersions: Set = [] for version in try storedVersions() where history.version(identifiedBy: version.id) == nil { newVersions.insert(version) try history.add(version, updatingPredecessorVersions: false) } for version in newVersions { try history.updateSuccessors(inPredecessorsOf: version) } } } /// Provides access to the history object in a serialized way, allowing access from any thread. /// Calls the block passed after getting exclusive history to the history object, and passes the history. public func queryHistory(in block: (History) throws ->Void) rethrows { try history.withLock { history in try block(history) } } public func historyIncludesVersions(identifiedBy versionIds: [Version.ID]) -> Bool { var valid = false queryHistory { history in valid = versionIds.allSatisfy { history.version(identifiedBy: $0) != nil } } return valid } } // MARK:- Storing Values and Versions extension Store { /// Convenience to avoid having to create Value.Change values yourself @discardableResult public func makeVersion(basedOnPredecessor versionId: Version.ID?, inserting insertedValues: [Value] = [], updating updatedValues: [Value] = [], removing removedIds: [Value.ID] = [], metadata: Version.Metadata = [:]) throws -> Version { let predecessors = versionId.flatMap { Version.Predecessors(idOfFirst: $0, idOfSecond: nil) } let inserts: [Value.Change] = insertedValues.map { .insert($0) } let updates: [Value.Change] = updatedValues.map { .update($0) } let removes: [Value.Change] = removedIds.map { .remove($0) } return try makeVersion(basedOn: predecessors, storing: inserts+updates+removes, metadata: metadata) } @discardableResult public func makeVersion(basedOnPredecessor version: Version.ID?, storing changes: [Value.Change], metadata: Version.Metadata = [:]) throws -> Version { let predecessors = version.flatMap { Version.Predecessors(idOfFirst: $0, idOfSecond: nil) } return try makeVersion(basedOn: predecessors, storing: changes, metadata: metadata) } /// Changes must include all updates to the map of the first predecessor. If necessary, preserves should be included to bring values /// from the second predecessor into the first predecessor map. @discardableResult internal func makeVersion(basedOn predecessors: Version.Predecessors?, storing changes: [Value.Change], metadata: Version.Metadata = [:]) throws -> Version { let version = Version(predecessors: predecessors, valueDataSize: changes.valueDataSize, metadata: metadata) try addVersion(version, storing: changes) return version } /// This method does not check consistency, and does not automatically update the map. /// It is assumed that any changes to the first predecessor that are needed in the map /// are present as preserves from the second predecessor. internal func addVersion(_ version: Version, storing changes: [Value.Change]) throws { guard !historyIncludesVersions(identifiedBy: [version.id]) else { throw Error.attemptToAddExistingVersion(version.id) } guard historyIncludesVersions(identifiedBy: version.predecessors?.ids ?? []) else { throw Error.attemptToAddVersionWithNonexistingPredecessors(version) } // Store values var valueDataSize: Int64 = 0 for change in changes { switch change { case .insert(let value), .update(let value): var newValue = value newValue.storedVersionId = version.id try self.store(newValue) valueDataSize += Int64(newValue.data.count) case .remove, .preserve, .preserveRemoval: continue } } // Update values map let deltas: [Map.Delta] = changes.map { change in switch change { case .insert(let value), .update(let value): let valueRef = Value.Reference(valueId: value.id, storedVersionId: version.id) var delta = Map.Delta(key: Map.Key(value.id.rawValue)) delta.addedValueReferences = [valueRef] return delta case .remove(let valueId), .preserveRemoval(let valueId): var delta = Map.Delta(key: Map.Key(valueId.rawValue)) delta.removedValueIdentifiers = [valueId] return delta case .preserve(let valueRef): var delta = Map.Delta(key: Map.Key(valueRef.valueId.rawValue)) delta.addedValueReferences = [valueRef] return delta } } try valuesMap.addVersion(version.id, basedOn: version.predecessors?.idOfFirst, applying: deltas) // Store version var versionWithDataSize = version versionWithDataSize.valueDataSize = valueDataSize try store(versionWithDataSize) // Add to history try history.withLock { history in do { try history.add(version, updatingPredecessorVersions: true) } catch History.Error.attemptToAddPreexistingVersion { throw Error.attemptToAddExistingVersion(version.id) } } } private func store(_ value: Value) throws { guard let zoneRef = value.zoneReference else { throw Error.attemptToStoreValueWithNoVersion } try valuesZone.store(value.data, for: zoneRef) } } // MARK:- Fetching Values extension Store { public func valueReference(id valueId: Value.ID, at versionId: Version.ID) throws -> Value.Reference? { return try valuesMap.valueReferences(matching: .init(valueId.rawValue), at: versionId).first } public func valueReferences(at version: Version.ID) throws -> [Value.Reference] { var refs: [Value.Reference] = [] try enumerate(version: version) { ref in refs.append(ref) } return refs } /// Convenient method to avoid having to create id types public func value(idString valueIdString: String, at versionId: Version.ID) throws -> Value? { return try value(id: .init(valueIdString), at: versionId) } public func value(id valueId: Value.ID, at versionId: Version.ID) throws -> Value? { let ref = try valueReference(id: valueId, at: versionId) return try ref.flatMap { try value(id: valueId, storedAt: $0.storedVersionId) } } public func value(id valueId: Value.ID, storedAt versionId: Version.ID) throws -> Value? { guard let data = try valuesZone.data(for: .init(key: valueId.rawValue, version: versionId)) else { return nil } let value = Value(id: valueId, storedVersionId: versionId, data: data) return value } public func value(storedAt valueReference: Value.Reference) throws -> Value? { return try value(id: valueReference.valueId, storedAt: valueReference.storedVersionId) } public func enumerate(version versionId: Version.ID, executingForEach block: (Value.Reference) throws -> Void) throws { try valuesMap.enumerateValueReferences(forVersionIdentifiedBy: versionId, executingForEach: block) } } // MARK:- Merging extension Store { public enum MergeHeadSelection { case all case allExceptBranches case allUnbranchedAndSpecificBranches([Branch]) case specificBranchesOnly([Branch]) } /// Whether there is more than one head public var hasMultipleHeads: Bool { var result: Bool = false queryHistory { history in result = history.headIdentifiers.count > 1 } return result } /// Finds any heads that have the branch in the metadata. public func heads(withBranch branch: Branch) -> [Version.ID] { var result: [Version.ID] = [] queryHistory { history in let heads = history.headIdentifiers result = heads.filter { id in let version = history.version(identifiedBy: id)! return branch.rawValue == version.metadata[.branch]?.value() } } return result } /// Merges heads into the version passed, which is usually a head itself. This is a convenience /// to save looping through all heads. /// If the version ends up being changed by the merging, the new version is returned, otherwise nil. public func mergeHeads(into version: Version.ID, resolvingWith arbiter: MergeArbiter, headSelection: MergeHeadSelection = .allExceptBranches, metadata: Version.Metadata = [:]) -> Version.ID? { var heads: Set = [] var versionsById: [Version.ID:Version] = [:] queryHistory { history in heads = history.headIdentifiers versionsById = .init(uniqueKeysWithValues: heads.map({ ($0, history.version(identifiedBy: $0)!) })) } heads.remove(version) heads = heads.filter { id in let version = versionsById[id]! let branch: String? = version.metadata[.branch]?.value() switch headSelection { case .all: return true case .allExceptBranches: return branch == nil case .allUnbranchedAndSpecificBranches(let branches): return branch == nil || branches.map({ $0.rawValue }).contains(branch) case .specificBranchesOnly(let branches): return branches.map({ $0.rawValue }).contains(branch) } } guard !heads.isEmpty else { return nil } var versionId: Version.ID = version for otherHead in heads { let newVersion = try! merge(version: versionId, with: otherHead, resolvingWith: arbiter, metadata: metadata) versionId = newVersion.id } return versionId } /// Will choose between a three way merge, and a two way merge, based on whether a common ancestor is found. public func merge(version firstVersionIdentifier: Version.ID, with secondVersionIdentifier: Version.ID, resolvingWith arbiter: MergeArbiter, metadata: Version.Metadata = [:]) throws -> Version { do { return try mergeRelated(version: firstVersionIdentifier, with: secondVersionIdentifier, resolvingWith: arbiter, metadata: metadata) } catch Error.noCommonAncestor { return try mergeUnrelated(version: firstVersionIdentifier, with: secondVersionIdentifier, resolvingWith: arbiter, metadata: metadata) } } /// Two-way merge between two versions that have no common ancestry. Effectively we assume an empty common ancestor, /// so that all changes are inserts, or conflicting twiceInserts. public func mergeUnrelated(version firstVersionIdentifier: Version.ID, with secondVersionIdentifier: Version.ID, resolvingWith arbiter: MergeArbiter, metadata: Version.Metadata = [:]) throws -> Version { var firstVersion, secondVersion: Version? var fastForwardVersion: Version? try history.withLock { history in firstVersion = history.version(identifiedBy: firstVersionIdentifier) secondVersion = history.version(identifiedBy: secondVersionIdentifier) guard firstVersion != nil, secondVersion != nil else { throw Error.missingVersion } // Check for fast forward if history.isAncestralLine(from: firstVersion!.id, to: secondVersion!.id) { fastForwardVersion = secondVersion } else if history.isAncestralLine(from: secondVersion!.id, to: firstVersion!.id) { fastForwardVersion = firstVersion } } if let fastForwardVersion = fastForwardVersion { return fastForwardVersion } return try merge(firstVersion!, and: secondVersion!, withCommonAncestor: nil, resolvingWith: arbiter, metadata: metadata) } /// Three-way merge between two versions, and a common ancestor. If no common ancestor is found, a .noCommonAncestor error is thrown. /// Conflicts are resolved using the MergeArbiter passed in. public func mergeRelated(version firstVersionIdentifier: Version.ID, with secondVersionIdentifier: Version.ID, resolvingWith arbiter: MergeArbiter, metadata: Version.Metadata = [:]) throws -> Version { var firstVersion, secondVersion, commonVersion: Version? var commonVersionIdentifier: Version.ID? try history.withLock { history in commonVersionIdentifier = try history.greatestCommonAncestor(ofVersionsIdentifiedBy: (firstVersionIdentifier, secondVersionIdentifier)) guard commonVersionIdentifier != nil else { throw Error.noCommonAncestor(firstVersion: firstVersionIdentifier, secondVersion: secondVersionIdentifier) } firstVersion = history.version(identifiedBy: firstVersionIdentifier) secondVersion = history.version(identifiedBy: secondVersionIdentifier) commonVersion = history.version(identifiedBy: commonVersionIdentifier!) guard firstVersion != nil, secondVersion != nil else { throw Error.missingVersion } } // Check for fast forward cases where no merge is needed if firstVersionIdentifier == commonVersionIdentifier { return secondVersion! } else if secondVersionIdentifier == commonVersionIdentifier { return firstVersion! } return try merge(firstVersion!, and: secondVersion!, withCommonAncestor: commonVersion!, resolvingWith: arbiter, metadata: metadata) } /// Two or three-way merge. Does no check to see if fast forwarding is possible. Will carry out the merge regardless of history. /// If a common ancestor is supplied, it is 3-way, and otherwise 2-way. private func merge(_ firstVersion: Version, and secondVersion: Version, withCommonAncestor commonAncestor: Version?, resolvingWith arbiter: MergeArbiter, metadata: Version.Metadata = [:]) throws -> Version { // Prepare merge let predecessors = Version.Predecessors(idOfFirst: firstVersion.id, idOfSecond: secondVersion.id) let diffs = try valuesMap.differences(between: firstVersion.id, and: secondVersion.id, withCommonAncestor: commonAncestor?.id) var merge = Merge(versions: (firstVersion, secondVersion), commonAncestor: commonAncestor) let forkTuples = diffs.map({ ($0.valueId, $0.valueFork) }) merge.forksByValueIdentifier = .init(uniqueKeysWithValues: forkTuples) // Resolve with arbiter var changes = try arbiter.changes(toResolve: merge, in: self) // Check changes resolve conflicts let idsInChanges = Set(changes.valueIds) for diff in diffs { if diff.valueFork.isConflicting && !idsInChanges.contains(diff.valueId) { throw Error.unresolvedConflict(valueId: diff.valueId, valueFork: diff.valueFork) } } // Must make sure any change that was made in the second predecessor is included, // via a 'preserve' if necessary. // This is so the map of the first predecessor is updated properly. for diff in diffs where !idsInChanges.contains(diff.valueId) { switch diff.valueFork { case .inserted(let branch) where branch == .second: fallthrough case .updated(let branch) where branch == .second: let ref = try valueReference(id: diff.valueId, at: secondVersion.id)! changes.append(.preserve(ref)) case .removed(let branch) where branch == .second: changes.append(.preserveRemoval(diff.valueId)) default: break } } return try makeVersion(basedOn: predecessors, storing: changes, metadata: metadata) } } // MARK:- Value Changes extension Store { /// Returns the changes actually made in the version passed. This is important for an exchange, for example, that wishes to /// store a set of changes. Note that it is not exactly equivalent to taking the diff between the version and one of its predecessors, /// because in that case, any changes made in the branch of the other predecessor will also be included as changes, when they don't /// really belong (ie they were actually made in the past) public func valueChanges(madeInVersionIdentifiedBy versionId: Version.ID) throws -> [Value.Change] { guard let version = try version(identifiedBy: versionId) else { throw Error.missingVersion } guard let predecessors = version.predecessors else { var changes: [Value.Change] = [] try valuesMap.enumerateValueReferences(forVersionIdentifiedBy: versionId) { ref in let v = try value(id: ref.valueId, storedAt: ref.storedVersionId)! changes.append(.insert(v)) } return changes } var changes: [Value.Change] = [] let p1 = predecessors.idOfFirst if let p2 = predecessors.idOfSecond { // Do a reverse-in-time fork, and negate the outcome let diffs = try valuesMap.differences(between: p1, and: p2, withCommonAncestor: versionId) for diff in diffs { switch diff.valueFork { case .twiceInserted: changes.append(.remove(diff.valueId)) case .twiceUpdated, .removedAndUpdated: let value = try self.value(id: diff.valueId, at: versionId)! changes.append(.update(value)) case .twiceRemoved: let value = try self.value(id: diff.valueId, at: versionId)! changes.append(.insert(value)) case .inserted: changes.append(.preserveRemoval(diff.valueId)) case .removed, .updated: let value = try self.value(id: diff.valueId, at: versionId)! changes.append(.preserve(value.reference!)) } } } else { changes = try valueChanges(madeBetween: p1, and: version.id) } return changes } /// Changes that can be applied to go from the first version to the second. Useful for "diffing", eg, updating UI by seeing what changed. public func valueChanges(madeBetween versionId1: Version.ID, and versionId2: Version.ID) throws -> [Value.Change] { guard let _ = try version(identifiedBy: versionId1), let _ = try version(identifiedBy: versionId2) else { throw Error.missingVersion } var changes: [Value.Change] = [] let diffs = try valuesMap.differences(between: versionId2, and: versionId1, withCommonAncestor: versionId1) for diff in diffs { switch diff.valueFork { case .inserted: let value = try self.value(id: diff.valueId, at: versionId2)! changes.append(.insert(value)) case .removed: changes.append(.remove(diff.valueId)) case .updated: let value = try self.value(id: diff.valueId, at: versionId2)! changes.append(.update(value)) case .removedAndUpdated, .twiceInserted, .twiceRemoved, .twiceUpdated: fatalError("Should not be possible with only a single branch") } } return changes } } // MARK:- Storing and Fetching Versions extension Store { fileprivate func store(_ version: Version) throws { try autoreleasepool { let (dir, file) = fileSystemLocation(forVersionIdentifiedBy: version.id) try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) let data = try JSONEncoder().encode(version) try data.write(to: file) } } /// Returns all versions of a value with the given identifier in the history. /// Order is topological, from recent to ancient. No timestamp ordering has been applied /// This can be expensive, as it iterates all history. public func versionIds(for valueId: Value.ID) throws -> [Version.ID] { var existingVersions: Set = [] var valueVersions: [Version.ID] = [] try queryHistory { history in for v in history { if let ref = try valueReference(id: valueId, at: v.id), !existingVersions.contains(ref.storedVersionId) { valueVersions.append(ref.storedVersionId) existingVersions.insert(ref.storedVersionId) } } } return valueVersions } /// Version ids found in store. This makes no use of the loaded history. internal func storedVersionIds(for valueId: Value.ID) throws -> [Version.ID] { let valueDirectoryURL = valuesDirectoryURL.appendingSplitPathComponent(valueId.rawValue) let enumerator = fileManager.enumerator(at: valueDirectoryURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])! let valueDirComponents = valueDirectoryURL.standardizedFileURL.pathComponents var versionIds: [Version.ID] = [] for any in enumerator { var isDirectory: ObjCBool = true guard let url = any as? URL else { continue } guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && !isDirectory.boolValue else { continue } guard url.pathExtension == "json" else { continue } let allComponents = url.standardizedFileURL.deletingPathExtension().pathComponents let versionComponents = allComponents[valueDirComponents.count...] let versionString = versionComponents.joined() versionIds.append(.init(versionString)) } return versionIds } /// Versions found in store. This makes no use of the loaded history. fileprivate func storedVersions() throws -> [Version] { try autoreleasepool { let enumerator = fileManager.enumerator(at: versionsDirectoryURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])! var versions: [Version] = [] let decoder = JSONDecoder() for any in enumerator { var isDirectory: ObjCBool = true guard let url = any as? URL else { continue } guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && !isDirectory.boolValue else { continue } guard url.pathExtension == "json" else { continue } let data = try Data(contentsOf: url) let version = try decoder.decode(Version.self, from: data) versions.append(version) } return versions } } public func version(identifiedBy versionId: Version.ID) throws -> Version? { var version: Version? queryHistory { history in version = history.version(identifiedBy: versionId) } return version } public var mostRecentHead: Version? { var version: Version? queryHistory { history in version = history.mostRecentHead } return version } } // MARK:- File System Locations fileprivate extension Store { func fileSystemLocation(forVersionIdentifiedBy identifier: Version.ID) -> (directoryURL: URL, fileURL: URL) { let fileURL = versionsDirectoryURL.appendingSplitPathComponent(identifier.rawValue).appendingPathExtension("json") let directoryURL = fileURL.deletingLastPathComponent() return (directoryURL: directoryURL, fileURL: fileURL) } } // MARK:- Path Utilities internal extension URL { /// Appends a path to the messaged URL that consists of a filename for which /// a prefix is taken as a subdirectory. Eg. `file:///root` might become /// `file:///root/fi/lename.jpg` when appending `filename.jpg` with `subDirectoryNameLength` of 2. func appendingSplitPathComponent(_ name: String, prefixLength: UInt = 2) -> URL { guard name.count > prefixLength else { return appendingPathComponent(name) } // Embed a subdirectory let index = name.index(name.startIndex, offsetBy: Int(prefixLength)) let prefix = String(name[.. private var currentVersionContinuation: AsyncStream.Continuation? private let _currentVersion: Mutex public var currentVersion: Version.ID { _currentVersion.withLock { $0 } } private func updateCurrentVersion(_ newValue: Version.ID) { let changed = _currentVersion.withLock { current -> Bool in guard current != newValue else { return false } current = newValue return true } if changed { persist() currentVersionContinuation?.yield(newValue) } } private class var defaultStoreDirectory: URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let rootDir = appSupport.appendingPathComponent("LLVS").appendingPathComponent("DefaultStore") return rootDir } private class var defaultCacheDirectory: URL { let cachesDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! let rootDir = cachesDir.appendingPathComponent("LLVS").appendingPathComponent("CoordinatorCache") return rootDir } /// This will setup a store in the default location (Applicaton Support). If you need more than one store, /// use `init(withStoreDirectoryAt:,cacheDirectoryAt:)` instead. public convenience init(snapshotPolicy: SnapshotPolicy = .disabled) throws { try self.init(withStoreDirectoryAt: Self.defaultStoreDirectory, cacheDirectoryAt: Self.defaultStoreDirectory, snapshotPolicy: snapshotPolicy) } /// Gives full control over where the store is (directory location), and where cached data should be kept (directory). /// The directories will be created if they do not exist. public init(withStoreDirectoryAt storeURL: URL, cacheDirectoryAt coordinatorCacheURL: URL, snapshotPolicy: SnapshotPolicy = .disabled) throws { self.snapshotPolicy = snapshotPolicy self.storeDirectoryURL = storeURL self.cacheDirectoryURL = coordinatorCacheURL self.cachedCoordinatorFileURL = cacheDirectoryURL.appendingPathComponent("Coordinator.json") try FileManager.default.createDirectory(at: storeURL, withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: coordinatorCacheURL, withIntermediateDirectories: true, attributes: nil) self.store = try Store(rootDirectoryURL: storeURL) self._currentVersion = Mutex(Version.ID()) // Set a temporary version. Final is in cache var continuation: AsyncStream.Continuation? self.currentVersionUpdates = AsyncStream { continuation = $0 } self.currentVersionContinuation = continuation try loadCache() } private func loadCache() throws { // Load state from cache let cachedData: CachedData var shouldPersist = false if let cached = self.cachedData { cachedData = cached } else { // Get most recent, or make first commit let version: Version.ID if let head = store.mostRecentHead { version = head.id } else { version = try store.makeVersion(basedOnPredecessor: nil, storing: []).id } cachedData = CachedData(currentVersionIdentifier: version) shouldPersist = true } // Set properties from cache updateCurrentVersion(cachedData.currentVersionIdentifier) if shouldPersist { persist() } } private var cachedData: CachedData? { let fileManager = FileManager() if fileManager.fileExists(atPath: self.cachedCoordinatorFileURL.path), let data = try? Data(contentsOf: self.cachedCoordinatorFileURL), let cached = try? JSONDecoder().decode(CachedData.self, from: data) { return cached } else { return nil } } /// Store cached data private func persist() { let cachedData = CachedData(exchangeRestorationData: exchange?.restorationState, currentVersionIdentifier: currentVersion) if let data = try? JSONEncoder().encode(cachedData) { try? data.write(to: cachedCoordinatorFileURL) } } // MARK: Heads public func heads(withBranch branch: Branch) -> [Version.ID] { store.heads(withBranch: branch) } /// Will attempt to get the first branch version, if one exists; if not, will return the current version. /// This is just a convenient way to get a base version. public func versionForBranchOrCurrentHead(for branch: Branch? = nil) -> Version.ID { branch.flatMap({ heads(withBranch: $0).first }) ?? currentVersion } // MARK: Saving /// You should use this to save instead of using the store directly, so that the /// coordinator can track versions. Otherwise you will need to merge to see the changes. public func save(_ changes: [Value.Change], in branch: Branch? = nil, metadata: Version.Metadata? = nil) throws { guard !changes.isEmpty || metadata != nil else { return } var metadata = metadata ?? defaultMetadataForNewVersions if let branch = branch { metadata[.branch] = .init(branch.rawValue) } updateCurrentVersion(try store.makeVersion(basedOnPredecessor: versionForBranchOrCurrentHead(for: branch), storing: changes, metadata: metadata).id) } public func save(inserting inserts: [Value] = [], updating updates: [Value] = [], removing removals: [Value.ID] = [], in branch: Branch? = nil, metadata: Version.Metadata? = nil) throws { guard !inserts.isEmpty || !updates.isEmpty || !removals.isEmpty || metadata != nil else { return } var metadata = metadata ?? defaultMetadataForNewVersions if let branch = branch { metadata[.branch] = .init(branch.rawValue) } updateCurrentVersion(try store.makeVersion(basedOnPredecessor: versionForBranchOrCurrentHead(for: branch), inserting: inserts, updating: updates, removing: removals, metadata: metadata).id) } // MARK: Fetching /// Pass a specific version, or nil for the current version public func valueReferences(at version: Version.ID? = nil) throws -> [Value.Reference] { try store.valueReferences(at: version ?? currentVersion) } public func values(at version: Version.ID? = nil) throws -> [Value] { try autoreleasepool { return try valueReferences(at: version).map { try store.value(storedAt: $0)! } } } public func value(idString: String) throws -> Value? { return try store.value(idString: idString, at: currentVersion) } public func value(id: Value.ID) throws -> Value? { return try store.value(id: id, at: currentVersion) } public func value(idString: String, on branch: Branch?) throws -> Value? { return try store.value(idString: idString, at: versionForBranchOrCurrentHead(for: branch)) } public func value(id: Value.ID, on branch: Branch?) throws -> Value? { return try store.value(id: id, at: versionForBranchOrCurrentHead(for: branch)) } // MARK: Sync public private(set) var isExchanging = false /// Serializer to ensure one exchange at a time. private var exchangeSerializer = ExchangeSerializer() /// This transfers data between cloud and local store, but does not alter the current branch or do any merging. /// It's a bit like a two-way version of Git's fetch. public func exchange() async throws { try await exchangeSerializer.enqueue { [self] in try await self.performExchange() } } private func performExchange() async throws { isExchanging = true defer { isExchanging = false } guard let exchange = exchange else { return } let _ = try await exchange.retrieve() let _ = try await exchange.send() // Upload snapshot if policy allows (fire and forget) Task { [weak self] in try? await self?.uploadSnapshotIfNeeded() } } /// Merging any extra heads, or fast forward to latest. It's a good idea to save data just before calling this, so that /// in view edits are committed. Returns true if the merge changed the current version; false otherwise. /// Note that the default behavior is not to merge in named branches. These are usually used for background work, and need to be merged in under controlled circumstances. @discardableResult public func merge(metadata: Version.Metadata? = nil, headSelection: Store.MergeHeadSelection = .allExceptBranches) -> Bool { let metadata = metadata ?? defaultMetadataForNewVersions let newVersion = self.store.mergeHeads(into: self.currentVersion, resolvingWith: self.mergeArbiter, headSelection: headSelection, metadata: metadata) if let newVersion = newVersion { updateCurrentVersion(newVersion) return true } else { return false } } // MARK: Snapshots /// Download and restore a cloud snapshot if available and compatible. /// Call before the first exchange on a new device. public func bootstrapFromSnapshot() async throws { guard let snapshotExchange = exchange as? SnapshotExchange, let snapshotStorage = store.storage as? SnapshotCapable else { return } // Check store has minimal history (≤ 1 version) — skip if already populated var versionCount = 0 store.queryHistory { history in versionCount = history.allVersionIdentifiers.count } guard versionCount <= 1 else { return } guard let manifest = try await snapshotExchange.retrieveSnapshotManifest() else { return } // Check format matches guard manifest.format == snapshotStorage.snapshotFormat else { return } // Download chunks to temp directory let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: nil) defer { try? FileManager.default.removeItem(at: tempDir) } for i in 0.. -self.snapshotPolicy.minimumInterval { return } // Skip if not enough new versions if currentVersionCount - manifest.versionCount < self.snapshotPolicy.minimumNewVersions { return } } // Write snapshot to temp directory let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: nil) defer { try? FileManager.default.removeItem(at: tempDir) } let manifest = try snapshotStorage.writeSnapshotChunks( storeRootURL: self.store.rootDirectoryURL, to: tempDir, maxChunkSize: 5_000_000 ) try await snapshotExchange.sendSnapshot(manifest: manifest, chunkProvider: { index in let chunkFile = tempDir.appendingPathComponent(String(format: "chunk-%03d", index)) return try Data(contentsOf: chunkFile) }) } } // MARK: - ExchangeSerializer /// Ensures only one exchange runs at a time using an AsyncStream as a FIFO queue. private final class ExchangeSerializer: @unchecked Sendable { private typealias Work = @Sendable () async throws -> Void private let stream: AsyncStream private let continuation: AsyncStream.Continuation init() { (stream, continuation) = AsyncStream.makeStream() Task { [stream] in for await work in stream { try? await work() } } } func enqueue(_ work: @escaping @Sendable () async throws -> Void) async throws { try await withCheckedThrowingContinuation { (done: CheckedContinuation) in continuation.yield { do { try await work() done.resume() } catch { done.resume(throwing: error) } } } } } ================================================ FILE: Sources/LLVS/Core/Value.swift ================================================ // // Value.swift // LLVS // // Created by Drew McCormack on 31/10/2018. // import Foundation public struct Value: Codable, Identifiable { public typealias ID = Identifier public var id: ID public var data: Data /// The identifier of the version in which this value was stored. Can be nil, if /// a value has not yet been stored. public internal(set) var storedVersionId: Version.ID? internal var zoneReference: ZoneReference? { guard let version = storedVersionId else { return nil } return ZoneReference(key: id.rawValue, version: version) } public var reference: Reference? { guard let version = storedVersionId else { return nil } return Reference(valueId: id, storedVersionId: version) } /// Convenience that saves creating IDs public init(idString: String, data: Data) { self.init(id: ID(idString), data: data) } /// If an id is not provided, a UUID will be used. The storedVersionId will be set to nil, because /// this value has not been stored yet. public init(id: ID = .init(UUID().uuidString), data: Data) { self.id = id self.data = data } internal init(id: ID, storedVersionId: Version.ID, data: Data) { self.id = id self.storedVersionId = storedVersionId self.data = data } } public extension Value { struct Reference: Codable, Hashable { public var valueId: ID public var storedVersionId: Version.ID } struct Identifier: RawRepresentable, Hashable, Codable { public var rawValue: String public init(rawValue: String = UUID().uuidString) { self.rawValue = rawValue } public init(_ rawValue: String) { self.init(rawValue: rawValue) } } enum Change: Codable { case insert(Value) case update(Value) case remove(ID) case preserve(Reference) case preserveRemoval(ID) enum CodingKeys: String, CodingKey { case insert, update, remove, preserve, preserveRemoval } enum Error: Swift.Error { case changeDecodingFailure } public func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) switch self { case let .insert(value): try c.encode(value, forKey: .insert) case let .update(value): try c.encode(value, forKey: .update) case let .remove(id): try c.encode(id, forKey: .remove) case let .preserve(ref): try c.encode(ref, forKey: .preserve) case let .preserveRemoval(id): try c.encode(id, forKey: .preserveRemoval) } } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) if let v = try? c.decode(Value.self, forKey: .insert) { self = .insert(v); return } if let v = try? c.decode(Value.self, forKey: .update) { self = .update(v); return } if let i = try? c.decode(ID.self, forKey: .remove) { self = .remove(i); return } if let r = try? c.decode(Reference.self, forKey: .preserve) { self = .preserve(r); return } if let i = try? c.decode(ID.self, forKey: .preserveRemoval) { self = .preserveRemoval(i); return } throw Error.changeDecodingFailure } } enum Fork: Equatable { public enum Branch: Equatable { case first case second var opposite: Branch { return self == .first ? .second : .first } } case inserted(Branch) case twiceInserted case removed(Branch) case twiceRemoved case updated(Branch) case twiceUpdated case removedAndUpdated(removedOn: Branch) public var isConflicting: Bool { switch self { case .inserted, .removed, .updated, .twiceRemoved: return false case .twiceInserted, .twiceUpdated, .removedAndUpdated: return true } } } } extension Array where Element == Value.Change { var valueIds: [Value.ID] { return self.map { change in switch change { case .insert(let value), .update(let value): return value.id case .remove(let identifier), .preserveRemoval(let identifier): return identifier case .preserve(let ref): return ref.valueId } } } var valueDataSize: Int64 { return self.reduce(0) { result, change in switch change { case .insert(let value), .update(let value): return result + Int64(value.data.count) case .remove, .preserveRemoval, .preserve: return result } } } } ================================================ FILE: Sources/LLVS/Core/Version.swift ================================================ // // Version.swift // llvs // // Created by Drew McCormack on 31/10/2018. // import Foundation public struct Version: Hashable, Identifiable { public typealias Metadata = [MetadataKey:MetadataValue] public struct MetadataKey: Hashable, Codable, RawRepresentable { public var rawValue: String public init(rawValue: String) { self.rawValue = rawValue } public static let branch = Self(rawValue: "__llvs_branch") } public struct MetadataValue: Codable { public let data: Data public init(data: Data) { self.data = data } public init(_ value: T) { self.data = try! JSONEncoder().encode(value) } public func value() -> T { try! JSONDecoder().decode(T.self, from: self.data) } } public typealias ID = Identifier public var id: ID = .init() public var predecessors: Predecessors? public var successors: Successors = .init() public var timestamp: TimeInterval public var valueDataSize: Int64? public var metadata: Metadata = [:] private enum CodingKeys: String, CodingKey { case identifier case predecessors case timestamp case valueDataSize case metadata } public init(id: ID = .init(), predecessors: Predecessors? = nil, valueDataSize: Int64, metadata: Metadata = [:]) { self.id = id self.predecessors = predecessors self.timestamp = Date().timeIntervalSinceReferenceDate self.metadata = metadata self.valueDataSize = valueDataSize } public static func == (lhs: Version, rhs: Version) -> Bool { lhs.id == rhs.id } public func hash(into hasher: inout Hasher) { hasher.combine(id) } } extension Version: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(ID.self, forKey: .identifier) predecessors = try container.decodeIfPresent(Predecessors.self, forKey: .predecessors) timestamp = try container.decode(TimeInterval.self, forKey: .timestamp) metadata = try container.decodeIfPresent(Metadata.self, forKey: .metadata) ?? [:] valueDataSize = try container.decodeIfPresent(Int64.self, forKey: .valueDataSize) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .identifier) try container.encodeIfPresent(predecessors, forKey: .predecessors) try container.encode(timestamp, forKey: .timestamp) try container.encodeIfPresent(valueDataSize, forKey: .valueDataSize) try container.encode(metadata, forKey: .metadata) } } extension Version { public struct Identifier: RawRepresentable, Codable, Hashable { public var rawValue: String public init(rawValue: String = UUID().uuidString) { self.rawValue = rawValue } public init(_ rawValue: String) { self.init(rawValue: rawValue) } } public struct Predecessors: Codable, Hashable { public internal(set) var idOfFirst: ID public internal(set) var idOfSecond: ID? public var ids: [ID] { var result = [idOfFirst] if let second = idOfSecond { result.append(second) } return result } internal init(idOfFirst: ID, idOfSecond: ID?) { self.idOfFirst = idOfFirst self.idOfSecond = idOfSecond } } public struct Successors: Codable, Hashable { public internal(set) var ids: Set internal init(ids: Set = []) { self.ids = ids } } } public extension Collection where Element == Version { var ids: [Version.ID] { return map { $0.id } } var idStrings: [String] { return map { $0.id.rawValue } } } public extension Collection where Element == Version.ID { var idStrings: [String] { return map { $0.rawValue } } } ================================================ FILE: Sources/LLVS/Core/Zone.swift ================================================ // // Zone.swift // LLVS // // Created by Drew McCormack on 02/12/2018. // import Foundation public struct ZoneReference: Codable, Hashable { public var key: String public var version: Version.ID } public protocol Zone { func store(_ data: Data, for reference: ZoneReference) throws // Default provided, but zone implementations can optimize this. func store(_ data: [Data], for references: [ZoneReference]) throws func data(for reference: ZoneReference) throws -> Data? // Default provided, but zone implementations can optimize this. func data(for references: [ZoneReference]) throws -> [Data?] } public extension Zone { func store(_ data: [Data], for references: [ZoneReference]) throws { try zip(data, references).forEach { data, ref in try store(data, for: ref) } } func data(for references: [ZoneReference]) throws -> [Data?] { return try references.map { try data(for: $0) } } } ================================================ FILE: Sources/LLVS/Exchanges/CloudFileSystem.swift ================================================ // // CloudFileSystem.swift // LLVS // // Created by Drew McCormack on 03/03/2026. // import Foundation /// Errors that can occur when interacting with a cloud file system. public enum CloudFileSystemError: Error { case fileNotFound case uploadFailed case downloadFailed case directoryListingFailed case authenticationFailed case serverError(statusCode: Int) } /// A protocol for file-level operations on a cloud storage service. /// /// Paths are relative strings (e.g. `"versions/ABC-123"`). /// Implementations map these to service-specific addressing. /// `upload` creates intermediate directories as needed. public protocol CloudFileSystem: AnyObject, Sendable { /// Returns `true` if a file exists at the given path. func fileExists(at path: String) async throws -> Bool /// Returns the file names (not full paths) of items in the directory at the given path. func contentsOfDirectory(at path: String) async throws -> [String] /// Uploads data to the given path, creating intermediate directories as needed. func upload(data: Data, to path: String) async throws /// Downloads data from the given path. func download(from path: String) async throws -> Data /// Removes the file at the given path. Does not throw if the file doesn't exist. func remove(at path: String) async throws /// Removes the directory at the given path and all its contents. Does not throw if the directory doesn't exist. func removeDirectory(at path: String) async throws } ================================================ FILE: Sources/LLVS/Exchanges/CloudFileSystemExchange.swift ================================================ // // CloudFileSystemExchange.swift // LLVS // // Created by Drew McCormack on 03/03/2026. // import Foundation /// An exchange that stores version and change data in a cloud file system. /// /// Implements `Exchange` and `SnapshotExchange` by delegating all I/O to a `CloudFileSystem`. /// The on-disk layout mirrors `FileSystemExchange`: /// /// ``` /// {basePath}/ /// versions/{versionId} -- JSON: {"version": Version} /// changes/{versionId} -- JSON: [Value.Change] /// snapshots/manifest.json -- JSON: SnapshotManifest /// snapshots/chunk-000... -- Binary chunk data /// ``` public final class CloudFileSystemExchange: Exchange, SnapshotExchange, @unchecked Sendable { public enum Error: Swift.Error { case versionFileInvalid case changesFileInvalid case snapshotChunkMissing(Int) } public let store: Store public let cloudFileSystem: CloudFileSystem /// A path prefix allowing multiple stores per cloud account. public let basePath: String public let newVersionsAvailable: AsyncStream private let newVersionsContinuation: AsyncStream.Continuation public var restorationState: Data? { get { return nil } set {} } private var versionsPath: String { basePath + "/versions" } private var changesPath: String { basePath + "/changes" } private var snapshotsPath: String { basePath + "/snapshots" } /// Creates a cloud file system exchange. /// - Parameters: /// - cloudFileSystem: The cloud file system to use for I/O. /// - store: The local LLVS store. /// - basePath: A path prefix for all cloud files (default: empty string). public init(cloudFileSystem: CloudFileSystem, store: Store, basePath: String = "") { self.cloudFileSystem = cloudFileSystem self.store = store self.basePath = basePath (self.newVersionsAvailable, self.newVersionsContinuation) = AsyncStream.makeStream() } deinit { newVersionsContinuation.finish() } // MARK: - Exchange public func prepareToRetrieve() async throws { } public func retrieveAllVersionIdentifiers() async throws -> [Version.ID] { do { let names = try await cloudFileSystem.contentsOfDirectory(at: versionsPath) return names.map { Version.ID($0) } } catch let error as CloudFileSystemError where error.isNotFound { return [] } } public func retrieveVersions(identifiedBy versionIds: [Version.ID]) async throws -> [Version] { try await versionIds.asyncMap { versionId in let path = self.versionsPath + "/\(versionId.rawValue)" let data = try await self.cloudFileSystem.download(from: path) if let version = try JSONDecoder().decode([String: Version].self, from: data)["version"] { return version } else { throw Error.versionFileInvalid } } } public func retrieveValueChanges(forVersionsIdentifiedBy versionIds: [Version.ID]) async throws -> [Version.ID: [Value.Change]] { var result: [Version.ID: [Value.Change]] = [:] for versionId in versionIds { let path = changesPath + "/\(versionId.rawValue)" let data = try await cloudFileSystem.download(from: path) let changes = try JSONDecoder().decode([Value.Change].self, from: data) result[versionId] = changes } return result } public func prepareToSend() async throws { } public func send(versionChanges: [VersionChanges]) async throws { for (version, valueChanges) in versionChanges { // Upload changes before version file for consistency let changesData = try JSONEncoder().encode(valueChanges) try await cloudFileSystem.upload(data: changesData, to: changesPath + "/\(version.id.rawValue)") let versionData = try JSONEncoder().encode(["version": version]) try await cloudFileSystem.upload(data: versionData, to: versionsPath + "/\(version.id.rawValue)") } } // MARK: - Snapshot Exchange public func retrieveSnapshotManifest() async throws -> SnapshotManifest? { let manifestPath = snapshotsPath + "/manifest.json" do { let exists = try await cloudFileSystem.fileExists(at: manifestPath) guard exists else { return nil } let data = try await cloudFileSystem.download(from: manifestPath) return try JSONDecoder().decode(SnapshotManifest.self, from: data) } catch let error as CloudFileSystemError where error.isNotFound { return nil } } public func retrieveSnapshotChunk(index: Int) async throws -> Data { let chunkPath = snapshotsPath + "/" + String(format: "chunk-%03d", index) do { return try await cloudFileSystem.download(from: chunkPath) } catch { throw Error.snapshotChunkMissing(index) } } public func sendSnapshot(manifest: SnapshotManifest, chunkProvider: @escaping @Sendable (Int) throws -> Data) async throws { // Remove previous snapshot if any try? await cloudFileSystem.removeDirectory(at: snapshotsPath) // Write chunks first for i in 0..(_ transform: @escaping (Element) async throws -> T) async throws -> [T] { var results: [T] = [] results.reserveCapacity(count) for element in self { try await results.append(transform(element)) } return results } } // MARK: - CloudFileSystemError Helpers extension CloudFileSystemError { var isNotFound: Bool { if case .fileNotFound = self { return true } if case .directoryListingFailed = self { return true } return false } } ================================================ FILE: Sources/LLVS/Exchanges/FileSystemExchange.swift ================================================ // // FileSystemExchange.swift // LLVS // // Created by Drew McCormack on 25/02/2019. // import Foundation public class FileSystemExchange: NSObject, Exchange, NSFilePresenter, SnapshotExchange { public enum Error: Swift.Error { case versionFileInvalid case changesFileInvalid case snapshotChunkMissing(Int) } public let store: Store private let minimumDelayBeforeNotifyingOfNewVersions = 1.0 public let newVersionsAvailable: AsyncStream private let newVersionsContinuation: AsyncStream.Continuation public let rootDirectoryURL: URL public var versionsDirectory: URL { return rootDirectoryURL.appendingPathComponent("versions") } public var changesDirectory: URL { return rootDirectoryURL.appendingPathComponent("changes") } public var snapshotsDirectory: URL { return rootDirectoryURL.appendingPathComponent("snapshots") } public let usesFileCoordination: Bool public var restorationState: Data? { get { return nil } set {} } fileprivate let fileManager = FileManager() fileprivate let queue = OperationQueue() public init(rootDirectoryURL: URL, store: Store, usesFileCoordination: Bool) { self.rootDirectoryURL = rootDirectoryURL self.store = store self.usesFileCoordination = usesFileCoordination (self.newVersionsAvailable, self.newVersionsContinuation) = AsyncStream.makeStream() super.init() try? fileManager.createDirectory(at: rootDirectoryURL, withIntermediateDirectories: true, attributes: nil) try? fileManager.createDirectory(at: versionsDirectory, withIntermediateDirectories: true, attributes: nil) try? fileManager.createDirectory(at: changesDirectory, withIntermediateDirectories: true, attributes: nil) if self.usesFileCoordination { NSFileCoordinator.addFilePresenter(self) } } deinit { if self.usesFileCoordination { NSFileCoordinator.removeFilePresenter(self) } newVersionsContinuation.finish() } public func prepareToRetrieve() async throws { } public func retrieveAllVersionIdentifiers() async throws -> [Version.ID] { try await coordinateFileAccess(.read) { let contents = try self.fileManager.contentsOfDirectory(at: self.versionsDirectory, includingPropertiesForKeys: nil, options: []) return contents.map({ Version.ID($0.lastPathComponent) }) } } public func retrieveVersions(identifiedBy versionIds: [Version.ID]) async throws -> [Version] { try await coordinateFileAccess(.read) { try versionIds.map { versionId in let url = self.versionsDirectory.appendingPathComponent(versionId.rawValue) let data = try Data(contentsOf: url) if let version = try JSONDecoder().decode([String:Version].self, from: data)["version"] { return version } else { throw Error.versionFileInvalid } } } } public func retrieveValueChanges(forVersionsIdentifiedBy versionIds: [Version.ID]) async throws -> [Version.ID: [Value.Change]] { try await coordinateFileAccess(.read) { try versionIds.reduce(into: [:]) { result, versionId in let url = self.changesDirectory.appendingPathComponent(versionId.rawValue) let data = try Data(contentsOf: url) let changes = try JSONDecoder().decode([Value.Change].self, from: data) result[versionId] = changes } } } public func prepareToSend() async throws { } public func send(versionChanges: [VersionChanges]) async throws { try await coordinateFileAccess(.write) { for (version, valueChanges) in versionChanges { let changesURL = self.changesDirectory.appendingPathComponent(version.id.rawValue) let changesData = try JSONEncoder().encode(valueChanges) try changesData.write(to: changesURL) let versionURL = self.versionsDirectory.appendingPathComponent(version.id.rawValue) let versionData = try JSONEncoder().encode(["version":version]) try versionData.write(to: versionURL) } } } private enum FileAccess { case read, write } private func coordinateFileAccess(_ access: FileAccess, by block: @escaping () throws -> T) async throws -> T { try await withCheckedThrowingContinuation { continuation in queue.addOperation { if self.usesFileCoordination { let coordinator = NSFileCoordinator(filePresenter: self) var coordError: NSError? let accessor: (URL) -> Void = { _ in do { let result = try block() continuation.resume(returning: result) } catch { continuation.resume(throwing: error) } } switch access { case .read: coordinator.coordinate(readingItemAt: self.rootDirectoryURL, options: [], error: &coordError, byAccessor: accessor) case .write: coordinator.coordinate(writingItemAt: self.rootDirectoryURL, options: [], error: &coordError, byAccessor: accessor) } if let error = coordError { continuation.resume(throwing: error) } } else { do { let result = try block() continuation.resume(returning: result) } catch { continuation.resume(throwing: error) } } } } } // MARK:- Snapshot Exchange public func retrieveSnapshotManifest() async throws -> SnapshotManifest? { try await withCheckedThrowingContinuation { continuation in queue.addOperation { let manifestURL = self.snapshotsDirectory.appendingPathComponent("manifest.json") guard self.fileManager.fileExists(atPath: manifestURL.path) else { continuation.resume(returning: nil) return } do { let data = try Data(contentsOf: manifestURL) let manifest = try JSONDecoder().decode(SnapshotManifest.self, from: data) continuation.resume(returning: manifest) } catch { continuation.resume(throwing: error) } } } } public func retrieveSnapshotChunk(index: Int) async throws -> Data { try await withCheckedThrowingContinuation { continuation in queue.addOperation { let chunkURL = self.snapshotsDirectory.appendingPathComponent(String(format: "chunk-%03d", index)) guard self.fileManager.fileExists(atPath: chunkURL.path) else { continuation.resume(throwing: Error.snapshotChunkMissing(index)) return } do { let data = try Data(contentsOf: chunkURL) continuation.resume(returning: data) } catch { continuation.resume(throwing: error) } } } } public func sendSnapshot(manifest: SnapshotManifest, chunkProvider: @escaping @Sendable (Int) throws -> Data) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in queue.addOperation { do { // Remove previous snapshot if any if self.fileManager.fileExists(atPath: self.snapshotsDirectory.path) { try self.fileManager.removeItem(at: self.snapshotsDirectory) } try self.fileManager.createDirectory(at: self.snapshotsDirectory, withIntermediateDirectories: true, attributes: nil) // Write chunks for i in 0.. private let newVersionsContinuation: AsyncStream.Continuation nonisolated public var restorationState: Data? { get { return nil } set {} } private var versionsByIdentifier: [Version.ID: Version] = [:] private var valueChangesByVersionIdentifier: [Version.ID: [Value.Change]] = [:] public init(store: Store) { self.store = store let (stream, continuation) = AsyncStream.makeStream() self.newVersionsAvailable = stream self.newVersionsContinuation = continuation } public func prepareToRetrieve() async throws { } public func retrieveAllVersionIdentifiers() async throws -> [Version.ID] { return Array(versionsByIdentifier.keys) } public func retrieveVersions(identifiedBy versionIds: [Version.ID]) async throws -> [Version] { var versions: [Version] = [] for id in versionIds { if let version = versionsByIdentifier[id] { versions.append(version) } } return versions } public func retrieveValueChanges(forVersionsIdentifiedBy versionIds: [Version.ID]) async throws -> [Version.ID: [Value.Change]] { var result: [Version.ID: [Value.Change]] = [:] for id in versionIds { if let changes = valueChangesByVersionIdentifier[id] { result[id] = changes } } return result } public func prepareToSend() async throws { } public func send(versionChanges: [VersionChanges]) async throws { for (version, valueChanges) in versionChanges { versionsByIdentifier[version.id] = version valueChangesByVersionIdentifier[version.id] = valueChanges } newVersionsContinuation.yield(()) } } ================================================ FILE: Sources/LLVS/Exchanges/MultipeerExchange.swift ================================================ // // MultipeerExchange.swift // LLVS // // Created by Drew McCormack on 01/03/2026. // import Foundation /// Protocol abstracting peer-to-peer data transport (e.g., wraps MCSession). /// Apps implement this to bridge their MultipeerConnectivity session. public protocol PeerTransport: AnyObject { func send(_ data: Data, toPeer peerID: String) throws } /// An Exchange that syncs between two peers via a direct data channel. /// /// The app must call `receiveData(_:)` from its MCSessionDelegate (or equivalent) /// whenever data arrives from the peer. MultipeerExchange handles the request-response /// protocol internally. /// /// Each peer runs its own MultipeerExchange instance. One peer's `send()`/`retrieve()` /// sends requests to the other peer, which responds automatically via `handleRequest`. public class MultipeerExchange: Exchange { public enum Error: Swift.Error { case transportUnavailable case timeout case invalidMessage case requestFailed(String) } // MARK: - PeerMessage struct PeerMessage: Codable { let id: String let type: MessageType var requestId: String? var payload: Data? enum MessageType: String, Codable { // Requests (client → server) case requestVersionIds case requestVersions case requestValueChanges // Responses (server → client) case responseVersionIds case responseVersions case responseValueChanges // Push (no response expected) case pushVersionChanges // Error case errorResponse } } // MARK: - Properties nonisolated public let store: Store public let peerID: String public weak var transport: PeerTransport? public let newVersionsAvailable: AsyncStream private let newVersionsContinuation: AsyncStream.Continuation public var restorationState: Data? { get { return nil } set {} } /// Actor that serializes internal state access private let state = State() private actor State { var pendingRequests: [String: CheckedContinuation] = [:] func addRequest(id: String, continuation: CheckedContinuation) { pendingRequests[id] = continuation } func removeRequest(id: String) -> CheckedContinuation? { pendingRequests.removeValue(forKey: id) } } private let requestTimeout: UInt64 = 30_000_000_000 // 30 seconds in nanoseconds // MARK: - Init public init(store: Store, peerID: String, transport: PeerTransport?) { self.store = store self.peerID = peerID self.transport = transport let (stream, continuation) = AsyncStream.makeStream() self.newVersionsAvailable = stream self.newVersionsContinuation = continuation } // MARK: - Incoming Data (call from MCSessionDelegate) /// Call this method from your MCSessionDelegate's `session(_:didReceive:fromPeer:)`. public func receiveData(_ data: Data) { guard let message = try? JSONDecoder().decode(PeerMessage.self, from: data) else { return } Task { switch message.type { case .requestVersionIds, .requestVersions, .requestValueChanges: await handleRequest(message) case .responseVersionIds, .responseVersions, .responseValueChanges: await handleResponse(message) case .pushVersionChanges: await handlePush(message) case .errorResponse: await handleResponse(message) } } } // MARK: - Exchange Protocol public func prepareToRetrieve() async throws {} public func prepareToSend() async throws {} public func retrieveAllVersionIdentifiers() async throws -> [Version.ID] { let responseData = try await sendRequest(type: .requestVersionIds, payload: nil) return try JSONDecoder().decode([Version.ID].self, from: responseData) } public func retrieveVersions(identifiedBy versionIds: [Version.ID]) async throws -> [Version] { guard !versionIds.isEmpty else { return [] } let payload = try JSONEncoder().encode(versionIds) let responseData = try await sendRequest(type: .requestVersions, payload: payload) return try JSONDecoder().decode([Version].self, from: responseData) } public func retrieveValueChanges(forVersionsIdentifiedBy versionIds: [Version.ID]) async throws -> [Version.ID: [Value.Change]] { guard !versionIds.isEmpty else { return [:] } let payload = try JSONEncoder().encode(versionIds) let responseData = try await sendRequest(type: .requestValueChanges, payload: payload) return try JSONDecoder().decode([Version.ID: [Value.Change]].self, from: responseData) } public func send(versionChanges: [VersionChanges]) async throws { guard !versionChanges.isEmpty else { return } let codable = versionChanges.map { CodableVersionChanges(version: $0.version, valueChanges: $0.valueChanges) } let payload = try JSONEncoder().encode(codable) let message = PeerMessage(id: UUID().uuidString, type: .pushVersionChanges, payload: payload) try sendMessage(message) } // MARK: - Request-Response private func sendRequest(type: PeerMessage.MessageType, payload: Data?) async throws -> Data { guard transport != nil else { throw Error.transportUnavailable } let requestId = UUID().uuidString let message = PeerMessage(id: requestId, type: type, payload: payload) return try await withCheckedThrowingContinuation { continuation in Task { await state.addRequest(id: requestId, continuation: continuation) do { try sendMessage(message) } catch { if let cont = await state.removeRequest(id: requestId) { cont.resume(throwing: error) } return } // Timeout Task { try? await Task.sleep(nanoseconds: requestTimeout) if let cont = await state.removeRequest(id: requestId) { cont.resume(throwing: Error.timeout) } } } } } private func sendMessage(_ message: PeerMessage) throws { guard let transport = transport else { throw Error.transportUnavailable } let data = try JSONEncoder().encode(message) try transport.send(data, toPeer: peerID) } // MARK: - Handling Incoming Messages private func handleRequest(_ message: PeerMessage) async { do { let responsePayload: Data let responseType: PeerMessage.MessageType switch message.type { case .requestVersionIds: var allIds: [Version.ID] = [] store.queryHistory { history in allIds = history.allVersionIdentifiers } responsePayload = try JSONEncoder().encode(allIds) responseType = .responseVersionIds case .requestVersions: guard let payload = message.payload else { throw Error.invalidMessage } let versionIds = try JSONDecoder().decode([Version.ID].self, from: payload) let versions: [Version] = try versionIds.compactMap { try store.version(identifiedBy: $0) } responsePayload = try JSONEncoder().encode(versions) responseType = .responseVersions case .requestValueChanges: guard let payload = message.payload else { throw Error.invalidMessage } let versionIds = try JSONDecoder().decode([Version.ID].self, from: payload) var changesByVersion: [Version.ID: [Value.Change]] = [:] for id in versionIds { changesByVersion[id] = try store.valueChanges(madeInVersionIdentifiedBy: id) } responsePayload = try JSONEncoder().encode(changesByVersion) responseType = .responseValueChanges default: return } let response = PeerMessage(id: UUID().uuidString, type: responseType, requestId: message.id, payload: responsePayload) try sendMessage(response) } catch { let errorResponse = PeerMessage( id: UUID().uuidString, type: .errorResponse, requestId: message.id, payload: error.localizedDescription.data(using: .utf8) ) try? sendMessage(errorResponse) } } private func handleResponse(_ message: PeerMessage) async { guard let requestId = message.requestId else { return } if let continuation = await state.removeRequest(id: requestId) { if message.type == .errorResponse { let description = message.payload.flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown error" continuation.resume(throwing: Error.requestFailed(description)) } else { continuation.resume(returning: message.payload ?? Data()) } } } private func handlePush(_ message: PeerMessage) async { guard let payload = message.payload else { return } do { let codable = try JSONDecoder().decode([CodableVersionChanges].self, from: payload) let versionChanges: [VersionChanges] = codable.map { ($0.version, $0.valueChanges) } for (version, valueChanges) in versionChanges { do { try store.addVersion(version, storing: valueChanges) } catch Store.Error.attemptToAddExistingVersion { // Ignore } } newVersionsContinuation.yield(()) } catch { log.error("Failed to handle push: \(error)") } } } // MARK: - Codable Helper private struct CodableVersionChanges: Codable { let version: Version let valueChanges: [Value.Change] } ================================================ FILE: Sources/LLVS/General/Cache.swift ================================================ // // Cache.swift // LLVS // // Created by Drew McCormack on 14/05/2019. // import Foundation import Synchronization /// Generational cache. Fills up each generation to a limit, then discards oldest creating a new generation. /// When you retrieve a value, it automatically adds that value to the newest generation, to keep it around. /// Creating generations is based on the number of values in the latest generation, not on time or data size. public final class Cache { private class Generation { private var valuesByIdentifier: [AnyHashable:ValueType] = [:] subscript(id: AnyHashable) -> ValueType? { get { return valuesByIdentifier[id] } set(newValue) { valuesByIdentifier[id] = newValue } } var count: Int { return valuesByIdentifier.count } } private struct State { var generations: [Generation] } public let numberOfGenerations: Int public let regenerationLimit: Int private let state: Mutex public init(numberOfGenerations: Int = 2, regenerationLimit: Int = 1000) { self.numberOfGenerations = max(1, numberOfGenerations) self.regenerationLimit = max(1, regenerationLimit) let generations: [Generation] = .init(repeating: Generation(), count: max(1, numberOfGenerations)) self.state = Mutex(State(generations: generations)) } public func setValue(_ value: ValueType, for identifier: AnyHashable) { state.withLock { state in regenerateIfNeeded(&state) state.generations.first![identifier] = value } } public func removeValue(for identifier: AnyHashable) { state.withLock { state in state.generations.forEach { generation in generation[identifier] = nil } } } public func value(for identifier: AnyHashable) -> ValueType? { state.withLock { state in if let generation = state.generations.first(where: { $0[identifier] != nil }) { let value = generation[identifier] state.generations.first![identifier] = value // Keep current by adding to most recent generation return value } else { return nil } } } public func purgeAllValues() { state.withLock { state in state.generations = .init(repeating: Generation(), count: self.numberOfGenerations) } } private func regenerateIfNeeded(_ state: inout State) { let generation = state.generations.first! if generation.count > regenerationLimit { regenerate(&state) } } private func regenerate(_ state: inout State) { let _ = state.generations.dropLast() state.generations.insert(Generation(), at: 0) } } ================================================ FILE: Sources/LLVS/General/DataCompression.swift ================================================ // // DataCompression.swift // LLVS // // Created by Drew McCormack on 01/03/2026. // import Foundation /// Transparent compression/decompression using LZFSE via Foundation's NSData API. /// Compressed data is prefixed with a 4-byte magic header ("LLZF") for reliable detection. /// Uncompressed data (legacy or too small to benefit) is returned as-is on read. public enum DataCompression { /// Magic bytes prepended to compressed data: "LLZF" private static let magic: [UInt8] = [0x4C, 0x4C, 0x5A, 0x46] /// Minimum data size worth attempting compression. private static let minimumCompressionSize = 64 /// Compresses data using LZFSE. Returns original data if the input is too small /// or compression doesn't reduce size (accounting for the 4-byte magic header). public static func compress(_ data: Data) -> Data { guard data.count > minimumCompressionSize else { return data } guard let compressed = try? (data as NSData).compressed(using: .lzfse) as Data else { return data } guard compressed.count + magic.count < data.count else { return data } var result = Data(magic) result.append(compressed) return result } /// Decompresses data if it starts with the "LLZF" magic header. /// Data without the header (legacy uncompressed or small data) is returned as-is. public static func decompressIfNeeded(_ data: Data) -> Data { guard data.count > magic.count else { return data } let s = data.startIndex guard data[s] == magic[0], data[s+1] == magic[1], data[s+2] == magic[2], data[s+3] == magic[3] else { return data } let compressed = Data(data[(s + magic.count)...]) guard let decompressed = try? (compressed as NSData).decompressed(using: .lzfse) as Data else { return data } return decompressed } } // MARK: - Value.Change Helpers internal extension DataCompression { /// Compresses the data payload within Value.Change insert/update cases. static func compressValueChanges(_ changes: [Value.Change]) -> [Value.Change] { return changes.map { change in switch change { case .insert(let value): var v = value v.data = compress(v.data) return .insert(v) case .update(let value): var v = value v.data = compress(v.data) return .update(v) case .remove, .preserve, .preserveRemoval: return change } } } /// Decompresses the data payload within Value.Change insert/update cases. static func decompressValueChanges(_ changes: [Value.Change]) -> [Value.Change] { return changes.map { change in switch change { case .insert(let value): var v = value v.data = decompressIfNeeded(v.data) return .insert(v) case .update(let value): var v = value v.data = decompressIfNeeded(v.data) return .update(v) case .remove, .preserve, .preserveRemoval: return change } } } } ================================================ FILE: Sources/LLVS/General/DynamicTaskBatcher.swift ================================================ // // DynamicTaskBatcher.swift // // // Created by Drew McCormack on 06/03/2020. // import Foundation /// Generates batches for a fixed number of asynchronous tasks, based on a cost criterion for each task. /// This is useful for asynchronously processing an array of tasks, where you have a cost function for each task, and want batches that try to avoid having too much cost. /// It can also dynamically adjust if a batch is not suitable, by growing and repeating the batch. public final class DynamicTaskBatcher { public enum Error: Swift.Error { case couldNotFurtherGrowFailingBatch } /// The outcome of a single batch execution. public enum BatchCompletionOutcome { /// Definitively succeeded or failed. Failure causes completion block to be called with error case definitive(Result) /// A half failure. Use this to indicate the batch did not succeed, but should be retried after growing. /// If it is not possible to grow the batch further, completion is called with error. case growBatchAndReexecute } public typealias TaskCostEvaluator = (_ index: Int) -> Float public typealias BatchExecuter = @Sendable (_ batchIndexRange: Range) async throws -> BatchCompletionOutcome public let numberOfTasks: Int /// Func that estimates the cost of a given task. Cost is between 0 and 1. /// A cost of 1 will result in a batch with only that one task. Task costs are tallied until /// they exceed 1, at which point the batch is complete and run. public let taskCostEvaluator: TaskCostEvaluator /// Executes a batch public let batchExecuter: BatchExecuter public init(numberOfTasks: Int, taskCostEvaluator: @escaping TaskCostEvaluator, batchExecuter: @escaping BatchExecuter) { self.numberOfTasks = numberOfTasks self.taskCostEvaluator = taskCostEvaluator self.batchExecuter = batchExecuter } // MARK: Execution private var currentBatchSize: Int = -1 private var completedCount: Int = 0 private var previousBatchNeedsReexecutionAfterGrowth = false public func start() async throws { self.currentBatchSize = -1 self.completedCount = 0 self.previousBatchNeedsReexecutionAfterGrowth = false try await startNextBatch() } private func calculateNextBatchSize() -> Int { let numberRemaining = numberOfTasks-completedCount defer { previousBatchNeedsReexecutionAfterGrowth = false } guard completedCount < numberOfTasks else { return 0 } guard !previousBatchNeedsReexecutionAfterGrowth else { return min(currentBatchSize+1, numberRemaining) } // Increase index until the accumulated cost is greater than 1 var i = completedCount var cost: Float = 0 while i < numberOfTasks { cost += taskCostEvaluator(i) if cost >= 1.0 { break } i += 1 } let newBatchSize = max(1, i-completedCount) return min(newBatchSize, numberRemaining) } private func startNextBatch() async throws { let numberRemaining = numberOfTasks-completedCount guard numberRemaining > 0 else { return } if previousBatchNeedsReexecutionAfterGrowth, completedCount + currentBatchSize == numberOfTasks { // Can't grow the batch anymore, and it is still failing. So fail outright throw Error.couldNotFurtherGrowFailingBatch } currentBatchSize = calculateNextBatchSize() let outcome = try await batchExecuter(completedCount.. { switch self { case let .failure(error): return .failure(error) case .success: return .success(()) } } var isSuccess: Bool { switch self { case .failure: return false case .success: return true } } } public extension ClosedRange where Bound == Int { func split(intoRangesOfLength size: Bound) -> [ClosedRange] { let end = upperBound+1 return stride(from: lowerBound, to: end, by: size).map { ClosedRange(uncheckedBounds: (lower: $0, upper: Swift.min($0+size-1, upperBound))) } } } @propertyWrapper public struct Atomic { private final class Storage: @unchecked Sendable { let mutex: Mutex init(_ value: Value) { self.mutex = Mutex(value) } } private let storage: Storage public init(wrappedValue value: Value) { self.storage = Storage(value) } public var wrappedValue: Value { get { storage.mutex.withLock { $0 } } set { storage.mutex.withLock { $0 = newValue } } } } ================================================ FILE: Sources/LLVS/General/Log.swift ================================================ // // Log.swift // LLVS // // Created by Drew McCormack on 10/05/19. // import Foundation import os public let log = Log() public class Log { public enum Level : Int, Comparable { case none case error case warning case trace case verbose public var stringValue: String { switch self { case .none: return "N" case .error: return "E" case .warning: return "W" case .trace: return "T" case .verbose: return "V" } } } public var level = Level.none @inline(__always) public final func verbose(_ messageClosure: @autoclosure () -> String, path: StaticString = #file, function: StaticString = #function, line: Int = #line) { if level >= .verbose { Log.append(messageClosure(), level: .verbose, path: path, function: function, line: line) } } @inline(__always) public final func trace(_ messageClosure: @autoclosure () -> String, path: StaticString = #file, function: StaticString = #function, line: Int = #line) { if level >= .trace { Log.append(messageClosure(), level: .trace, path: path, function: function, line: line) } } @inline(__always) public final func warning(_ messageClosure: @autoclosure () -> String, path: StaticString = #file, function: StaticString = #function, line: Int = #line) { if level >= .warning { Log.append(messageClosure(), level: .warning, path: path, function: function, line: line) } } @inline(__always) public final func error(_ messageClosure: @autoclosure () -> String, path: StaticString = #file, function: StaticString = #function, line: Int = #line) { if level >= .error { Log.append(messageClosure(), level: .error, path: path, function: function, line: line) } } @inline(__always) public final class func append(_ messageClosure: @autoclosure () -> String, level: Level, path: StaticString, function: StaticString, line: Int = #line) { let filename = (String(describing: path) as NSString).lastPathComponent let text = "\(level.rawValue) \(filename)(\(line)) : \(function) : \(messageClosure())" os_log("%{public}@", text) } } @inline(__always) public func <(a: Log.Level, b: Log.Level) -> Bool { return a.rawValue < b.rawValue } ================================================ FILE: Sources/LLVS/Utilities/ArrayDiff.swift ================================================ // // ArrayMerge.swift // LLVS // // Created by Drew McCormack on 02/04/2019. // import Foundation public extension Array where Element: Equatable { func diff(leadingTo newArray: [Element]) -> ArrayDiff { return ArrayDiff(originalValues: self, finalValues: newArray) } func applying(_ arrayDiff: ArrayDiff) -> [Element] { var new = self new.apply(arrayDiff) return new } mutating func apply(_ arrayDiff: ArrayDiff) { for diff in arrayDiff.incrementalChanges { apply(diff) } } mutating func apply(_ diff: ArrayDiff.IncrementalChange) { switch diff { case let .delete(index, _): guard indices ~= index else { return } remove(at: index) case let .insert(finalIndex, value): let insertIndex = Swift.min(finalIndex, count) insert(value, at: insertIndex) } } } /// Uses longest common subsequence algorithm to find difference between two arrays. /// Can be used to update to take the deletions and insertions /// applied to one array, and apply them to a related array. /// See https://en.wikipedia.org/wiki/Longest_common_subsequence_problem public struct ArrayDiff { /// IncrementalChange indicates a change to the original array. /// Indexes of deletions are relative to the original indexes of the original array. /// Indexes of insertions are given relative both the original and final array. public enum IncrementalChange: Equatable { case insert(finalIndex: Int, value: T) case delete(originalIndex: Int, value: T) public var isDeletion: Bool { if case .delete = self { return true } return false } public var isInsertion: Bool { if case .insert = self { return true } return false } public var index: Int { switch self { case let .delete(index, _), let .insert(index, _): return index } } } /// Changes are ordered so that you can apply them in order to the original array, /// and end up with the final array. Deletions come first, indexes according to the /// original array. They are in reversed order, applying to the end first. /// The insertions are next, with the indexes corresponding to the final array. /// They apply from the beginning toward the end, ie, standard order. public private(set) var incrementalChanges: [IncrementalChange] = [] public init(withChanges incrementalChanges: [IncrementalChange]) { self.incrementalChanges = incrementalChanges } public init(originalValues: [T], finalValues: [T]) { let lcs = LongestCommonSubsequence(originalValues: originalValues, finalValues: finalValues) self.incrementalChanges = lcs.incrementalChanges } /// Type used to stage intermediate form of merged changes private struct MergedChange { enum Position { case first, second } var deletions: [Position:IncrementalChange?] = [.first: nil, .second: nil] var insertions: [Position:[IncrementalChange]] = [.first: [], .second: []] } /// Creates a new diff from two existing ones, by merging them. Can be used for a 3 way merge. /// Can pass a merge policy if needed to handle case where two insertions conflict. public init(merging first: ArrayDiff, with second: ArrayDiff) { var mergedChangesByOriginalIndex: [Int:MergedChange] = [:] func addDeletions(in changes: [IncrementalChange], position: MergedChange.Position) { for change in changes { switch change { case let .delete(i, _): var m = mergedChangesByOriginalIndex[i, default: MergedChange()] m.deletions[position] = change mergedChangesByOriginalIndex[i] = m case .insert: break } } } addDeletions(in: first.incrementalChanges, position: .first) addDeletions(in: second.incrementalChanges, position: .second) func addInsertions(from changes: [IncrementalChange], position: MergedChange.Position) { let insertions = changes.filter({ $0.isInsertion }) var originalIndex = -1 var finalIndex = -1 for (i, insertion) in insertions.enumerated() { let insertionsContiguous = i > 0 && (insertions[i].index - insertions[i-1].index == 1) while !insertionsContiguous, insertion.index != finalIndex { let deletions = mergedChangesByOriginalIndex[originalIndex]?.deletions if deletions?[position] != nil { finalIndex -= 1 } originalIndex += 1 finalIndex += 1 } var m = mergedChangesByOriginalIndex[originalIndex, default: MergedChange()] m.insertions[position]!.append(insertion) mergedChangesByOriginalIndex[originalIndex] = m finalIndex += 1 } } addInsertions(from: first.incrementalChanges, position: .first) addInsertions(from: second.incrementalChanges, position: .second) // Build result from merged changes var resultDeletions: [IncrementalChange] = [] var resultInsertions: [IncrementalChange] = [] var finalIndex = -1 var previousOriginalIndex = -1 for originalIndex in mergedChangesByOriginalIndex.keys.sorted() { let mergedChange = mergedChangesByOriginalIndex[originalIndex]! // Update final index for any items with no changes finalIndex += originalIndex - previousOriginalIndex // Add deletion if let change = mergedChange.deletions[.first]! ?? mergedChange.deletions[.second]!, case let .delete(_, value) = change { resultDeletions.append(.delete(originalIndex: originalIndex, value: value)) } // Add insertions for case let .insert(_, value) in mergedChange.insertions[.first]! + mergedChange.insertions[.second]! { resultInsertions.append(.insert(finalIndex: finalIndex, value: value)) finalIndex += 1 } previousOriginalIndex = originalIndex } self.init(withChanges: resultDeletions.reversed() + resultInsertions) } } internal final class LongestCommonSubsequence { typealias Change = ArrayDiff.IncrementalChange public let originalValues: [T] public let finalValues: [T] public private(set) var originalIndexesOfCommonElements: [Int] = [] public private(set) var finalIndexesOfCommonElements: [Int] = [] public private(set) var incrementalChanges: [Change] = [] public var length: Int { guard !originalValues.isEmpty, !finalValues.isEmpty else { return 0 } return table[(originalValues.count-1, finalValues.count-1)].length } private let table: Table public init(originalValues: [T], finalValues: [T]) { self.originalValues = originalValues self.finalValues = finalValues self.table = Table(originalLength: self.originalValues.count, newLength: self.finalValues.count) fillTable() findLongestSubsequence() } private func coordinate(to neighbor: Table.Neighbor, of coordinate: Table.Coordinate) -> Table.Coordinate { return neighbor.coordinate(from: coordinate) } private func fillTable() { for row in 0.. top.length { subsequence.contributors = [.left] subsequence.length = left.length } else if top.length > left.length { subsequence.contributors = [.top] subsequence.length = top.length } else { subsequence.contributors = [.top, .left] subsequence.length = top.length } table[coord] = subsequence } } } private func findLongestSubsequence() { // Begin at end and walk back to origin var deletions: [Change] = [] var insertions: [Change] = [] var coord = (originalValues.count-1, finalValues.count-1) while coord.0 > -1 || coord.1 > -1 { let sub = table[coord] // Determine the preferred neighbor. // Update coord to that neighbor when finished this iteration. var preferred: Table.Neighbor? defer { coord = preferred!.coordinate(from: coord) } // Try to move diagonally to top-left preferred = sub.contributors.first { neighbor in let neighborSub = table[neighbor.coordinate(from: coord)] return neighborSub.length < sub.length } guard preferred == nil else { originalIndexesOfCommonElements.insert(coord.0, at: 0) finalIndexesOfCommonElements.insert(coord.1, at: 0) continue } // Otherwise pick first option preferred = sub.contributors.first switch preferred! { case .left: if coord.1 == -1, coord.0 == -1 { break } let delta: Change = .insert(finalIndex: coord.1, value: finalValues[coord.1]) insertions.insert(delta, at: 0) case .top: if coord.1 == -1, coord.0 == -1 { break } let delta: Change = .delete(originalIndex: coord.0, value: originalValues[coord.0]) deletions.insert(delta, at: 0) case .topLeft: fatalError() } } // Order changes so that deletions come before insertions, and deletions begin at // the end of the array. In this way, you can apply // the changes in order to transform from the original to the final array, and not // have to be concerned with indexes. incrementalChanges = deletions.reversed() + insertions } } fileprivate extension LongestCommonSubsequence { /// Memoization table final class Table: CustomDebugStringConvertible { typealias Coordinate = (original: Int, new: Int) enum Neighbor: String, CustomDebugStringConvertible { case left case top case topLeft var offset: Coordinate { switch self { case .left: return (0,-1) case .top: return (-1,0) case .topLeft: return (-1,-1) } } func coordinate(from index: Coordinate) -> Coordinate { let offset = self.offset return (original: index.original + offset.original, new: index.new + offset.new) } var debugDescription: String { return self.rawValue } } struct Subsequence { typealias Length = Int var length: Length = 0 var contributors: [Neighbor] = [] } let originalLength: Int let newLength: Int private var subsequences: [Subsequence] init(originalLength: Int, newLength: Int) { self.originalLength = originalLength self.newLength = newLength self.subsequences = .init(repeating: Subsequence(), count: (originalLength+1) * (newLength+1)) for row in 0.. Subsequence { get { let i = (coordinates.original+1) * (newLength+1) + (coordinates.new+1) return subsequences[i] } set(newValue) { let i = (coordinates.original+1) * (newLength+1) + (coordinates.new+1) subsequences[i] = newValue } } var debugDescription: String { var result = "" for row in -1.. private let newVersionsContinuation: AsyncStream.Continuation public var restorationState: Data? { get { try? JSONEncoder().encode(restoration) } set { if let data = newValue, let info = try? JSONDecoder().decode(RestorationInfo.self, from: data) { restoration = info } } } fileprivate lazy var temporaryDirectory: URL = { let result = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try? FileManager.default.createDirectory(at: result, withIntermediateDirectories: true, attributes: nil) return result }() /// - Parameters: /// - store: The LLVS store to sync. /// - client: An authenticated `BoxClient` instance. /// - rootFolderID: The Box folder ID to use as root for LLVS data. public init(store: Store, client: BoxClient, rootFolderID: String) { self.store = store self.client = client self.rootFolderID = rootFolderID let (stream, continuation) = AsyncStream.makeStream() self.newVersionsAvailable = stream self.newVersionsContinuation = continuation } // MARK: - Prepare public func prepareToRetrieve() async throws { try await ensureFoldersExist() } public func prepareToSend() async throws { try await ensureFoldersExist() } // MARK: - FolderBasedExchange public var versionsFolderID: String? { restoration.versionsFolderID } public var changesFolderID: String? { restoration.changesFolderID } public func notifyNewVersionsAvailable() { newVersionsContinuation.yield(()) } public func listFiles(inFolder folderID: String) async throws -> [String: String] { let items = try await listAllItems(inFolder: folderID) var fileMap: [String: String] = [:] for item in items where !item.isFolder { fileMap[item.name] = item.id } return fileMap } public func downloadData(forFile fileID: String) async throws -> Data { let tempURL = temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: tempURL) } guard let savedURL = try await client.downloads.downloadFile(fileId: fileID, downloadDestinationUrl: tempURL) else { throw Error.downloadFailed } return try Data(contentsOf: savedURL) } public func uploadData(_ data: Data, named name: String, toFolder folderID: String) async throws { let attributes = UploadFileRequestBodyAttributesField( name: name, parent: UploadFileRequestBodyAttributesParentField(id: folderID) ) let body = UploadFileRequestBody( attributes: attributes, file: Utils.generateByteStreamFromBuffer(buffer: data) ) _ = try await client.uploads.uploadFile(requestBody: body) } // MARK: - Box SDK Helpers private func ensureFoldersExist() async throws { if restoration.versionsFolderID != nil && restoration.changesFolderID != nil { return } let versionsFolderID = try await createSubfolderIfNeeded(named: "versions", inFolder: rootFolderID) let changesFolderID = try await createSubfolderIfNeeded(named: "changes", inFolder: rootFolderID) restoration.versionsFolderID = versionsFolderID restoration.changesFolderID = changesFolderID } private func createSubfolderIfNeeded(named name: String, inFolder parentID: String) async throws -> String { let items = try await listAllItems(inFolder: parentID) if let existing = items.first(where: { $0.name == name && $0.isFolder }) { return existing.id } let body = CreateFolderRequestBody( name: name, parent: CreateFolderRequestBodyParentField(id: parentID) ) let folder = try await client.folders.createFolder(requestBody: body) return folder.id } private struct ItemInfo { let id: String let name: String let isFolder: Bool } private func listAllItems(inFolder folderID: String) async throws -> [ItemInfo] { var allItems: [ItemInfo] = [] var marker: String? = nil repeat { let queryParams = GetFolderItemsQueryParams(usemarker: true, marker: marker, limit: 1000) let items = try await client.folders.getFolderItems(folderId: folderID, queryParams: queryParams) if let entries = items.entries { for entry in entries { switch entry { case .fileFull(let file): if let name = file.name { allItems.append(ItemInfo(id: file.id, name: name, isFolder: false)) } case .folderMini(let folder): if let name = folder.name { allItems.append(ItemInfo(id: folder.id, name: name, isFolder: true)) } default: break } } } marker = items.nextMarker } while marker != nil return allItems } // MARK: - Restoration fileprivate struct RestorationInfo: Codable { var versionsFolderID: String? var changesFolderID: String? } } ================================================ FILE: Sources/LLVSCloudKit/CloudKitExchange.swift ================================================ // // CloudKitExchange // LLVS // // Created by Drew McCormack on 16/03/2019. // import Foundation import CloudKit import LLVS public class CloudKitExchange: Exchange { public enum CloudDatabaseDescription { case privateDatabaseWithCustomZone(CKContainer, zoneIdentifier: String) case privateDatabaseWithDefaultZone(CKContainer) case publicDatabase(CKContainer) case sharedDatabase(CKContainer, zoneIdentifier: String) var database: CKDatabase { switch self { case let .privateDatabaseWithCustomZone(container, _): return container.privateCloudDatabase case let .privateDatabaseWithDefaultZone(container): return container.privateCloudDatabase case let .publicDatabase(container): return container.publicCloudDatabase case let .sharedDatabase(container, _): return container.sharedCloudDatabase } } var zoneIdentifier: String? { switch self { case let .privateDatabaseWithCustomZone(_, zoneIdentifier), let .sharedDatabase(_, zoneIdentifier): return zoneIdentifier default: return nil } } } public enum Error: Swift.Error { case couldNotGetVersionFromRecord case noZoneFound case invalidValueChangesDataInRecord case snapshotManifestDecodingFailed case snapshotChunkMissing(Int) case snapshotChunkAssetMissing(Int) } fileprivate lazy var temporaryDirectory: URL = { let result = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try? FileManager.default.createDirectory(at: result, withIntermediateDirectories: true, attributes: nil) return result }() /// The store the exchange is updating. public var store: Store /// Client to inform of updates public let newVersionsAvailable: AsyncStream private let newVersionsContinuation: AsyncStream.Continuation /// A store identifier identifies the store in the cloud. This allows multiple stores to use a shared zone like the public database. public let storeIdentifier: String /// Used only for the private database, when syncing via a custom zone. public let zoneIdentifier: String? /// Can be private, shared or public database. For private, it is best to provide a zone identifier. public let database: CKDatabase /// The custom zone being used in the private database, if there is one. public let zone: CKRecordZone? /// Use to make dependencies when working with a custom zone private let createZoneOperation: CKModifyRecordZonesOperation? /// Zone identifier if we are using a custom zone private var zoneID: CKRecordZone.ID? { guard let zoneIdentifier = zoneIdentifier else { return nil } return CKRecordZone.ID(zoneName: zoneIdentifier, ownerName: CKCurrentUserDefaultName) } /// Restoration state @Atomic private var restoration: Restoration = .init() /// Limit to use for CloudKit fetches. Should be less than actual limit (ie 400) private let cloudKitFetchLimit = 200 /// For single user syncing, it is best to use a zone. In that case, pass in the private database and a zone identifier. /// Otherwise, you will be using the default zone in whichever database you pass. public init(with store: Store, storeIdentifier: String, cloudDatabaseDescription: CloudDatabaseDescription) { self.store = store self.storeIdentifier = storeIdentifier self.zoneIdentifier = cloudDatabaseDescription.zoneIdentifier self.database = cloudDatabaseDescription.database self.zone = zoneIdentifier.flatMap { CKRecordZone(zoneName: $0) } (self.newVersionsAvailable, self.newVersionsContinuation) = AsyncStream.makeStream() if database.databaseScope == .private, let zone = self.zone { self.createZoneOperation = CKModifyRecordZonesOperation(recordZonesToSave: [zone], recordZoneIDsToDelete: nil) self.database.add(self.createZoneOperation!) } else { self.createZoneOperation = nil } } deinit { newVersionsContinuation.finish() } /// Remove a zone, if there is one. Otherwise will give error. public func removeZone() async throws { log.trace("Removing zone") guard let zone = zone else { throw Error.noZoneFound } try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in database.delete(withRecordZoneID: zone.zoneID) { zoneID, error in if let error = error { log.error("Removing zone failed: \(error)") continuation.resume(throwing: error) } else { log.trace("Removed zone") continuation.resume() } } } } } // MARK:- Querying Versions in Cloud fileprivate extension CloudKitExchange { /// Uses the zone changes API. Requires a custom zone. func fetchCloudZoneChanges() async throws { log.trace("Fetching cloud changes") try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration() config.desiredKeys = [] config.previousServerChangeToken = self.restoration.fetchRecordChangesToken let operation = CKFetchRecordZoneChangesOperation() operation.recordZoneIDs = [self.zoneID!] operation.configurationsByRecordZoneID = [self.zoneID! : config] operation.addDependency(self.createZoneOperation!) operation.fetchAllChanges = true operation.recordChangedBlock = { record in let versionId = Version.ID(record.recordID.recordName) self.restoration.versionsInCloud.insert(versionId) log.verbose("Found record for version: \(versionId)") } operation.recordZoneFetchCompletionBlock = { zoneID, token, clientData, moreComing, error in self.restoration.fetchRecordChangesToken = token log.verbose("Stored iCloud token: \(String(describing: token))") } operation.fetchRecordZoneChangesCompletionBlock = { error in if let error = error as? CKError, error.code == .changeTokenExpired || error.code == .partialFailure { self.restoration.fetchRecordChangesToken = nil self.restoration.versionsInCloud = [] log.error("iCloud token expired. Cleared cached data") // Retry Task { do { try await self.fetchCloudZoneChanges() continuation.resume() } catch { continuation.resume(throwing: error) } } } else if let error = error { continuation.resume(throwing: error) } else { log.trace("Fetched changes") continuation.resume() } } self.database.add(operation) } } enum QueryInfo { case query(CKQuery) case cursor(CKQueryOperation.Cursor) func makeQueryOperation() -> CKQueryOperation { switch self { case let .cursor(cursor): return CKQueryOperation(cursor: cursor) case let .query(query): return CKQueryOperation(query: query) } } } func makeRecordsQuery() -> CKQuery { let predicate: NSPredicate if let lastQueryDate = restoration.lastQueryDate { predicate = NSPredicate(format: "storeIdentifier = %@ AND (modificationDate >= %@)", storeIdentifier, lastQueryDate as NSDate) } else { predicate = NSPredicate(format: "storeIdentifier = %@", storeIdentifier) } return CKQuery(recordType: CKRecord.ExchangeType.Version.rawValue, predicate: predicate) } /// Get any new version identifiers in cloud func queryDatabaseForNewVersions() async throws { log.trace("Querying cloud for new versions") let query = makeRecordsQuery() do { let records = try await queryDatabase(with: .query(query)) let versionIds = records.map { Version.ID($0.recordID.recordName) } self.restoration.versionsInCloud.formUnion(versionIds) let modificationDates = records.map { $0.modificationDate! } self.restoration.lastQueryDate = max(self.restoration.lastQueryDate ?? Date.distantPast, modificationDates.max() ?? Date.distantPast ) } catch let error as CKError where error.code == .unknownItem { // Probably don't have data in cloud yet. Ignore error self.restoration.lastQueryDate = Date.distantPast } } /// Used when no zone is available. Eg. the public database. func queryDatabase(with queryInfo: QueryInfo) async throws -> [CKRecord] { log.trace("Querying cloud changes") return try await withCheckedThrowingContinuation { continuation in let operation = queryInfo.makeQueryOperation() var records: [CKRecord] = [] operation.recordFetchedBlock = { record in records.append(record) } operation.queryCompletionBlock = { cursor, error in if let cursor = cursor { Task { do { let moreRecords = try await self.queryDatabase(with: .cursor(cursor)) continuation.resume(returning: records + moreRecords) } catch { continuation.resume(throwing: error) } } } else { if let error = error { log.error("Failed to fetch new versions: \(error)") continuation.resume(throwing: error) } else { continuation.resume(returning: records) } } } self.database.add(operation) } } } // MARK:- Retrieving public extension CloudKitExchange { func prepareToRetrieve() async throws { log.trace("Preparing to retrieve") if zone != nil { try await fetchCloudZoneChanges() } else { try await queryDatabaseForNewVersions() } } func retrieveVersions(identifiedBy versionIds: [Version.ID]) async throws -> [Version] { log.trace("Retrieving versions: \(versionIds)") guard !versionIds.isEmpty else { return [] } // Use batches, because CloudKit will give limit error at 400 records let batchRanges = (0...versionIds.count-1).split(intoRangesOfLength: cloudKitFetchLimit) var versions: [Version] = [] for range in batchRanges { let batchVersionIds = Array(versionIds[range]) let batchVersions = try await retrieve(batchOfVersionsIdentifiedBy: batchVersionIds) versions.append(contentsOf: batchVersions) } return versions } /// Assumes that the batch size is less than the limits imposed by CloudKit (ie 400) private func retrieve(batchOfVersionsIdentifiedBy versionIds: [Version.ID]) async throws -> [Version] { log.trace("Retrieving versions") return try await withCheckedThrowingContinuation { continuation in let recordIDs = versionIds.map { CKRecord.ID(recordName: $0.rawValue, zoneID: self.zoneID ?? .default) } let fetchOperation = CKFetchRecordsOperation(recordIDs: recordIDs) fetchOperation.desiredKeys = [CKRecord.ExchangeKey.version.rawValue] fetchOperation.fetchRecordsCompletionBlock = { recordsByRecordID, error in guard error == nil else { continuation.resume(throwing: error!) return } do { try autoreleasepool { var versions: [Version] = [] for record in recordsByRecordID!.values { try autoreleasepool { if let data = record.exchangeValue(forKey: .version) as? Data, let version = try JSONDecoder().decode([Version].self, from: data).first { versions.append(version) } else { throw Error.couldNotGetVersionFromRecord } } } log.verbose("Retrieved versions: \(versions)") continuation.resume(returning: versions) } } catch { continuation.resume(throwing: error) } } self.database.add(fetchOperation) } } func retrieveAllVersionIdentifiers() async throws -> [Version.ID] { log.verbose("Retrieved all versions: \(restoration.versionsInCloud.map({ $0.rawValue }))") return Array(restoration.versionsInCloud) } func retrieveValueChanges(forVersionsIdentifiedBy versionIds: [Version.ID]) async throws -> [Version.ID: [Value.Change]] { log.trace("Retrieving value changes for versions: \(versionIds)") guard !versionIds.isEmpty else { return [:] } // Use batches of length 200, because CloudKit will give limit error at 400 records let batchRanges = (0...versionIds.count-1).split(intoRangesOfLength: cloudKitFetchLimit) var changesByVersionId: [Version.ID: [Value.Change]] = [:] for range in batchRanges { let batchVersionIds = Array(versionIds[range]) let newChanges = try await retrieve(batchOfValueChangesForVersionsIdentifiedBy: batchVersionIds) changesByVersionId.merge(newChanges) { current, _ in current } } return changesByVersionId } /// Retrieves a batch of value changes, assuming batch is smaller than the CloudKit limit private func retrieve(batchOfValueChangesForVersionsIdentifiedBy versionIds: [Version.ID]) async throws -> [Version.ID: [Value.Change]] { log.trace("Retrieving value changes for versions: \(versionIds)") return try await withCheckedThrowingContinuation { continuation in let recordIDs = versionIds.map { CKRecord.ID(recordName: $0.rawValue, zoneID: self.zoneID ?? .default) } let fetchOperation = CKFetchRecordsOperation(recordIDs: recordIDs) fetchOperation.desiredKeys = [CKRecord.ExchangeKey.valueChanges.rawValue, CKRecord.ExchangeKey.valueChangesAsset.rawValue] fetchOperation.fetchRecordsCompletionBlock = { recordsByRecordID, error in autoreleasepool { guard error == nil, let recordsByRecordID = recordsByRecordID else { continuation.resume(throwing: error!) return } do { let changesByVersion: [(Version.ID, [Value.Change])] = try recordsByRecordID.map { keyValue in let record = keyValue.value let recordID = keyValue.key let data: Data if let d = record.exchangeValue(forKey: .valueChanges) as? Data { data = d } else if let asset = record.exchangeValue(forKey: .valueChangesAsset) as? CKAsset, let url = asset.fileURL { data = try Data(contentsOf: url) } else { throw Error.invalidValueChangesDataInRecord } let valueChanges: [Value.Change] = try JSONDecoder().decode([Value.Change].self, from: data) log.verbose("Retrieved value changes for \(recordID.recordName): \(valueChanges)") return (Version.ID(recordID.recordName), valueChanges) } continuation.resume(returning: .init(uniqueKeysWithValues: changesByVersion)) } catch { log.error("Failed to retrieve: \(error)") continuation.resume(throwing: error) } } } self.database.add(fetchOperation) } } } // MARK:- Sending public extension CloudKitExchange { func prepareToSend() async throws { if zone != nil { try await fetchCloudZoneChanges() } else { try await queryDatabaseForNewVersions() } } func send(versionChanges: [VersionChanges]) async throws { log.trace("Sending versions: \(versionChanges.map({ $0.0.id }))") log.verbose("Value changes: \(versionChanges)") guard !versionChanges.isEmpty else { return } // Use batches of length 200, because CloudKit will give limit error at 400 records let batchRanges = (0...versionChanges.count-1).split(intoRangesOfLength: cloudKitFetchLimit) for range in batchRanges { let batchChanges = versionChanges[range] try await send(batchOfVersionChanges: batchChanges) } } private func send(batchOfVersionChanges versionChanges: ArraySlice) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in do { try autoreleasepool { var tempFileURLs: [URL] = [] let records: [CKRecord] = try versionChanges.map { t in let version = t.version let valueChanges = t.valueChanges let recordID = CKRecord.ID(recordName: version.id.rawValue, zoneID: zoneID ?? .default) let record = CKRecord(recordType: .init(CKRecord.ExchangeType.Version.rawValue), recordID: recordID) let versionData = try JSONEncoder().encode([version]) // Use an array, because JSON needs root dict or array let changesData = try JSONEncoder().encode(valueChanges) record.setExchangeValue(versionData, forKey: .version) record.setExchangeValue(storeIdentifier, forKey: .storeIdentifier) // Use an asset for bigger values (>10Kb) if changesData.count <= 10000 { record.setExchangeValue(changesData, forKey: .valueChanges) } else { let tempFileURL = temporaryDirectory.appendingPathComponent(UUID().uuidString) try changesData.write(to: tempFileURL) let asset = CKAsset(fileURL: tempFileURL) record.setExchangeValue(asset, forKey: .valueChangesAsset) tempFileURLs.append(tempFileURL) } return record } let modifyOperation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil) modifyOperation.isAtomic = true modifyOperation.savePolicy = .allKeys modifyOperation.modifyRecordsCompletionBlock = { _, _, error in tempFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } if let error = error { log.error("Failed to send: \(error)") continuation.resume(throwing: error) } else { log.trace("Succeeded in sending") continuation.resume() } } self.database.add(modifyOperation) } } catch { log.error("Failed to send: \(error)") continuation.resume(throwing: error) } } } } // MARK:- Snapshot Exchange extension CloudKitExchange: SnapshotExchange { public func retrieveSnapshotManifest() async throws -> SnapshotManifest? { log.trace("Retrieving snapshot manifest from CloudKit") return try await withCheckedThrowingContinuation { continuation in let recordName = "\(storeIdentifier)_snapshot_manifest" let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID ?? .default) let operation = CKFetchRecordsOperation(recordIDs: [recordID]) operation.desiredKeys = [CKRecord.ExchangeKey.snapshotManifest.rawValue] if let createZoneOp = createZoneOperation { operation.addDependency(createZoneOp) } operation.fetchRecordsCompletionBlock = { recordsByID, error in if let ckError = error as? CKError { if ckError.code == .unknownItem { continuation.resume(returning: nil) return } if ckError.code == .partialFailure, let partialErrors = ckError.partialErrorsByItemID, partialErrors.values.contains(where: { ($0 as? CKError)?.code == .unknownItem }) { continuation.resume(returning: nil) return } continuation.resume(throwing: ckError) return } guard let record = recordsByID?[recordID], let manifestData = record.exchangeValue(forKey: .snapshotManifest) as? Data else { continuation.resume(returning: nil) return } do { let manifest = try JSONDecoder().decode(SnapshotManifest.self, from: manifestData) log.trace("Retrieved snapshot manifest: \(manifest.snapshotId)") continuation.resume(returning: manifest) } catch { log.error("Failed to decode snapshot manifest: \(error)") continuation.resume(throwing: Error.snapshotManifestDecodingFailed) } } self.database.add(operation) } } public func retrieveSnapshotChunk(index: Int) async throws -> Data { log.trace("Retrieving snapshot chunk \(index) from CloudKit") return try await withCheckedThrowingContinuation { continuation in let recordName = "\(storeIdentifier)_snapshot_chunk_\(index)" let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID ?? .default) let operation = CKFetchRecordsOperation(recordIDs: [recordID]) operation.desiredKeys = [CKRecord.ExchangeKey.snapshotChunkData.rawValue] if let createZoneOp = createZoneOperation { operation.addDependency(createZoneOp) } operation.fetchRecordsCompletionBlock = { recordsByID, error in if let error = error { log.error("Failed to retrieve snapshot chunk \(index): \(error)") continuation.resume(throwing: Error.snapshotChunkMissing(index)) return } guard let record = recordsByID?[recordID], let asset = record.exchangeValue(forKey: .snapshotChunkData) as? CKAsset, let fileURL = asset.fileURL else { log.error("Snapshot chunk \(index) has no asset") continuation.resume(throwing: Error.snapshotChunkAssetMissing(index)) return } do { let data = try Data(contentsOf: fileURL) log.trace("Retrieved snapshot chunk \(index): \(data.count) bytes") continuation.resume(returning: data) } catch { log.error("Failed to read snapshot chunk \(index) asset: \(error)") continuation.resume(throwing: error) } } self.database.add(operation) } } public func sendSnapshot(manifest: SnapshotManifest, chunkProvider: @escaping @Sendable (Int) throws -> Data) async throws { log.trace("Sending snapshot to CloudKit: \(manifest.chunkCount) chunks") try await deleteExcessSnapshotChunks(keepingCount: manifest.chunkCount) try await uploadSnapshotChunks(manifest: manifest, chunkProvider: chunkProvider) try await uploadSnapshotManifest(manifest) } // MARK: Snapshot Helpers private func deleteExcessSnapshotChunks(keepingCount: Int) async throws { log.trace("Querying for excess snapshot chunks beyond index \(keepingCount)") let predicate = NSPredicate(format: "storeIdentifier = %@ AND snapshotChunkIndex >= %d", storeIdentifier, keepingCount) let query = CKQuery(recordType: CKRecord.ExchangeType.SnapshotChunk.rawValue, predicate: predicate) do { let records = try await queryDatabase(with: .query(query)) if records.isEmpty { log.trace("No excess snapshot chunks to delete") } else { log.trace("Deleting \(records.count) excess snapshot chunks") try await deleteRecords(records.map { $0.recordID }) } } catch let error as CKError where error.code == .unknownItem { // No records to delete } } private func deleteRecords(_ recordIDs: [CKRecord.ID]) async throws { guard !recordIDs.isEmpty else { return } let batchRanges = (0...recordIDs.count-1).split(intoRangesOfLength: cloudKitFetchLimit) for range in batchRanges { let batchIDs = Array(recordIDs[range]) try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let operation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: batchIDs) operation.modifyRecordsCompletionBlock = { _, _, error in if let error = error { log.error("Failed to delete records: \(error)") continuation.resume(throwing: error) } else { continuation.resume() } } self.database.add(operation) } } } private func uploadSnapshotChunks(manifest: SnapshotManifest, chunkProvider: @escaping (Int) throws -> Data) async throws { guard manifest.chunkCount > 0 else { return } let batchRanges = (0...manifest.chunkCount-1).split(intoRangesOfLength: cloudKitFetchLimit) for range in batchRanges { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in do { var tempFileURLs: [URL] = [] let records: [CKRecord] = try range.map { index in let chunkData = try chunkProvider(index) let recordName = "\(self.storeIdentifier)_snapshot_chunk_\(index)" let recordID = CKRecord.ID(recordName: recordName, zoneID: self.zoneID ?? .default) let record = CKRecord(recordType: .init(CKRecord.ExchangeType.SnapshotChunk.rawValue), recordID: recordID) record.setExchangeValue(self.storeIdentifier, forKey: .storeIdentifier) record.setExchangeValue(index, forKey: .snapshotChunkIndex) let tempFileURL = self.temporaryDirectory.appendingPathComponent(UUID().uuidString) try chunkData.write(to: tempFileURL) let asset = CKAsset(fileURL: tempFileURL) record.setExchangeValue(asset, forKey: .snapshotChunkData) tempFileURLs.append(tempFileURL) return record } let operation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil) operation.savePolicy = .allKeys operation.modifyRecordsCompletionBlock = { _, _, error in tempFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } if let error = error { log.error("Failed to upload snapshot chunks: \(error)") continuation.resume(throwing: error) } else { log.trace("Uploaded snapshot chunks \(range)") continuation.resume() } } self.database.add(operation) } catch { log.error("Failed to prepare snapshot chunks: \(error)") continuation.resume(throwing: error) } } } } private func uploadSnapshotManifest(_ manifest: SnapshotManifest) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in do { let manifestData = try JSONEncoder().encode(manifest) let recordName = "\(storeIdentifier)_snapshot_manifest" let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID ?? .default) let record = CKRecord(recordType: .init(CKRecord.ExchangeType.SnapshotManifest.rawValue), recordID: recordID) record.setExchangeValue(manifestData, forKey: .snapshotManifest) record.setExchangeValue(storeIdentifier, forKey: .storeIdentifier) let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil) operation.savePolicy = .allKeys operation.modifyRecordsCompletionBlock = { _, _, error in if let error = error { log.error("Failed to upload snapshot manifest: \(error)") continuation.resume(throwing: error) } else { log.trace("Uploaded snapshot manifest") continuation.resume() } } self.database.add(operation) } catch { log.error("Failed to encode snapshot manifest: \(error)") continuation.resume(throwing: error) } } } } // MARK:- Subscriptions public extension CloudKitExchange { func subscribeForPushNotifications() { log.trace("Subscribing for CloudKit push notifications") let info = CKSubscription.NotificationInfo() info.shouldSendContentAvailable = true let predicate = NSPredicate(value: true) let subscription = CKQuerySubscription(recordType: .init(CKRecord.ExchangeType.Version.rawValue), predicate: predicate, subscriptionID: CKRecord.ExchangeSubscription.VersionCreated.rawValue, options: CKQuerySubscription.Options.firesOnRecordCreation) subscription.notificationInfo = info database.save(subscription) { (_, error) in if let error = error { log.error("Error creating subscription: \(error)") } else { log.trace("Successfully subscribed") } } } } // MARK:- Restoration extension CloudKitExchange { public var restorationState: Data? { get { try? JSONEncoder().encode(restoration) } set { if let newValue = newValue, let state = try? JSONDecoder().decode(Restoration.self, from: newValue) { restoration = state } } } fileprivate struct Restoration: Codable { enum CodingKeys: String, CodingKey { case versionsInCloud, fetchRecordChangesToken, lastQueryDate } /// Set of all version ids in cloud var versionsInCloud: Set = [] /// Used for private database with custom zone var fetchRecordChangesToken: CKServerChangeToken? /// Used when there is no custom zone var lastQueryDate: Date? init() {} init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) versionsInCloud = try container.decode(type(of: versionsInCloud), forKey: .versionsInCloud) if let tokenData = try container.decodeIfPresent(Data.self, forKey: .fetchRecordChangesToken) { fetchRecordChangesToken = try NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData) } lastQueryDate = try container.decodeIfPresent(Date.self, forKey: .lastQueryDate) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(versionsInCloud, forKey: .versionsInCloud) let tokenData = try fetchRecordChangesToken.flatMap { try NSKeyedArchiver.archivedData(withRootObject: $0, requiringSecureCoding: false) } try container.encodeIfPresent(tokenData, forKey: .fetchRecordChangesToken) try container.encodeIfPresent(lastQueryDate, forKey: .lastQueryDate) } } } // MARK:- CKRecord fileprivate extension CKRecord { enum ExchangeSubscription: String { case VersionCreated } enum ExchangeType: String { case Version = "LLVS_Version" case SnapshotManifest = "LLVS_SnapshotManifest" case SnapshotChunk = "LLVS_SnapshotChunk" } enum ExchangeKey: String { case version, storeIdentifier, valueChanges, valueChangesAsset case snapshotManifest, snapshotChunkIndex, snapshotChunkData } func exchangeValue(forKey key: ExchangeKey) -> Any? { return value(forKey: key.rawValue) } func setExchangeValue(_ value: Any, forKey key: ExchangeKey) { setValue(value, forKey: key.rawValue) } } ================================================ FILE: Sources/LLVSGoogleDrive/GoogleDriveAuthenticator.swift ================================================ // // GoogleDriveAuthenticator.swift // LLVSGoogleDrive // // Created by Drew McCormack on 03/03/2026. // import Foundation import LLVS #if canImport(AuthenticationServices) import AuthenticationServices #endif /// Manages OAuth 2.0 authentication for Google Drive. /// /// Handles the full OAuth 2.0 authorization code flow: opening the browser, /// exchanging the authorization code for tokens, storing tokens in the Keychain, /// and automatically refreshing expired access tokens. /// /// Interactive authorization requires `AuthenticationServices` and is available /// on iOS 16+ and macOS 13+. /// /// ```swift /// let config = GoogleDriveAuthenticator.Configuration( /// clientID: "your-client-id.apps.googleusercontent.com", /// redirectURI: "com.yourapp:/oauth2callback" /// ) /// let authenticator = GoogleDriveAuthenticator(configuration: config) /// /// // First time: interactive authorization /// await authenticator.authorize(presenting: window) /// /// // Create file system — tokens refresh automatically /// let fs = GoogleDriveFileSystem(authenticator: authenticator) /// ``` public final class GoogleDriveAuthenticator: @unchecked Sendable { // MARK: - Configuration public struct Configuration: Sendable { /// The OAuth 2.0 client ID from the Google Cloud Console. public let clientID: String /// The redirect URI registered in the Google Cloud Console. public let redirectURI: String /// OAuth 2.0 scopes. Defaults to `drive.file` (per-file access). public let scopes: [String] public init( clientID: String, redirectURI: String, scopes: [String] = ["https://www.googleapis.com/auth/drive.file"] ) { self.clientID = clientID self.redirectURI = redirectURI self.scopes = scopes } } // MARK: - Stored Credential private struct StoredCredential: Codable { var accessToken: String var refreshToken: String var expiresAt: Date } // MARK: - Properties public let configuration: Configuration private static let authorizationURL = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")! private static let tokenURL = URL(string: "https://oauth2.googleapis.com/token")! private var credential: StoredCredential? private var keychainService: String { "com.llvs.googledrive.\(configuration.clientID)" } private lazy var session: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 return URLSession(configuration: config) }() // MARK: - Initialization public init(configuration: Configuration) { self.configuration = configuration self.credential = loadCredentialFromKeychain() } // MARK: - Public API /// Whether the user has previously authorized (has a stored refresh token). public var isAuthorized: Bool { credential?.refreshToken != nil } /// Returns a valid access token, refreshing if expired. public func validAccessToken() async throws -> String { guard let cred = credential else { throw CloudFileSystemError.authenticationFailed } // If token is still valid (with 60-second buffer), return it if cred.expiresAt.timeIntervalSinceNow > 60 { return cred.accessToken } return try await refreshAccessToken(refreshToken: cred.refreshToken) } /// Clears stored tokens and deauthorizes. public func deauthorize() { credential = nil deleteCredentialFromKeychain() } // MARK: - Interactive Authorization #if canImport(AuthenticationServices) @MainActor public func authorize(presenting anchor: ASPresentationAnchor) async throws { let code = try await obtainAuthorizationCode(presenting: anchor) try await exchangeCodeForTokens(code) } @MainActor private func obtainAuthorizationCode(presenting anchor: ASPresentationAnchor) async throws -> String { let scope = configuration.scopes.joined(separator: " ") var components = URLComponents(url: Self.authorizationURL, resolvingAgainstBaseURL: false)! components.queryItems = [ URLQueryItem(name: "client_id", value: configuration.clientID), URLQueryItem(name: "redirect_uri", value: configuration.redirectURI), URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "scope", value: scope), URLQueryItem(name: "access_type", value: "offline"), URLQueryItem(name: "prompt", value: "consent"), ] let authURL = components.url! guard let callbackScheme = URL(string: configuration.redirectURI)?.scheme else { throw CloudFileSystemError.authenticationFailed } return try await withCheckedThrowingContinuation { continuation in let anchorProvider = AnchorProvider(anchor: anchor) let session = ASWebAuthenticationSession( url: authURL, callbackURLScheme: callbackScheme ) { callbackURL, error in withExtendedLifetime(anchorProvider) {} if let error { continuation.resume(throwing: error) return } guard let callbackURL, let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { continuation.resume(throwing: CloudFileSystemError.authenticationFailed) return } continuation.resume(returning: code) } session.presentationContextProvider = anchorProvider session.prefersEphemeralWebBrowserSession = false session.start() } } private final class AnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding { let anchor: ASPresentationAnchor init(anchor: ASPresentationAnchor) { self.anchor = anchor } func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { anchor } } #endif // MARK: - Token Exchange private func exchangeCodeForTokens(_ code: String) async throws { let body = [ "code": code, "client_id": configuration.clientID, "redirect_uri": configuration.redirectURI, "grant_type": "authorization_code", ] let tokenResponse = try await performTokenRequest(body) guard let accessToken = tokenResponse["access_token"] as? String, let refreshToken = tokenResponse["refresh_token"] as? String, let expiresIn = tokenResponse["expires_in"] as? Int else { throw CloudFileSystemError.authenticationFailed } let cred = StoredCredential( accessToken: accessToken, refreshToken: refreshToken, expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)) ) credential = cred saveCredentialToKeychain(cred) } private func refreshAccessToken(refreshToken: String) async throws -> String { let body = [ "refresh_token": refreshToken, "client_id": configuration.clientID, "grant_type": "refresh_token", ] let tokenResponse = try await performTokenRequest(body) guard let accessToken = tokenResponse["access_token"] as? String, let expiresIn = tokenResponse["expires_in"] as? Int else { throw CloudFileSystemError.authenticationFailed } let cred = StoredCredential( accessToken: accessToken, refreshToken: refreshToken, expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)) ) credential = cred saveCredentialToKeychain(cred) return accessToken } private func performTokenRequest(_ body: [String: String]) async throws -> [String: Any] { var request = URLRequest(url: Self.tokenURL) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") let bodyString = body.map { "\($0.key)=\(urlEncode($0.value))" }.joined(separator: "&") request.httpBody = bodyString.data(using: .utf8) let (data, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200..<300).contains(statusCode) else { throw CloudFileSystemError.authenticationFailed } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CloudFileSystemError.authenticationFailed } return json } private func urlEncode(_ string: String) -> String { string.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? string } // MARK: - Keychain private func saveCredentialToKeychain(_ credential: StoredCredential) { guard let data = try? JSONEncoder().encode(credential) else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: "credential", ] SecItemDelete(query as CFDictionary) var addQuery = query addQuery[kSecValueData as String] = data SecItemAdd(addQuery as CFDictionary, nil) } private func loadCredentialFromKeychain() -> StoredCredential? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: "credential", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { return nil } return try? JSONDecoder().decode(StoredCredential.self, from: data) } private func deleteCredentialFromKeychain() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: "credential", ] SecItemDelete(query as CFDictionary) } } ================================================ FILE: Sources/LLVSGoogleDrive/GoogleDriveFileSystem.swift ================================================ // // GoogleDriveFileSystem.swift // LLVSGoogleDrive // // Created by Drew McCormack on 03/03/2026. // import Foundation import LLVS /// A `CloudFileSystem` backed by the Google Drive REST API v3 using `URLSession`. /// /// Google Drive uses file IDs rather than paths to identify files and folders. /// This backend bridges the path-based `CloudFileSystem` protocol onto Google Drive's /// ID-based API by walking the folder hierarchy to resolve paths to IDs. /// /// Resolved IDs are cached to minimize API calls. The cache is invalidated /// when items are deleted. /// /// Two initialization paths are available: /// ```swift /// // Static access token (app manages refresh externally) /// let fs = GoogleDriveFileSystem(accessToken: "your-token") /// /// // Authenticator with auto-refresh /// let fs = GoogleDriveFileSystem(authenticator: authenticator) /// ``` public final class GoogleDriveFileSystem: CloudFileSystem, @unchecked Sendable { // MARK: - Properties private let tokenProvider: @Sendable () async throws -> String /// Cache mapping absolute paths to Google Drive folder IDs. private var folderIDCache: [String: String] = ["/": "root"] /// Cache mapping absolute file paths to Google Drive file IDs. private var fileIDCache: [String: String] = [:] private static let apiBaseURL = URL(string: "https://www.googleapis.com/drive/v3/")! private static let uploadBaseURL = URL(string: "https://www.googleapis.com/upload/drive/v3/")! private static let folderMimeType = "application/vnd.google-apps.folder" private lazy var session: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 60 config.timeoutIntervalForResource = 3600 return URLSession(configuration: config) }() // MARK: - Initialization /// Creates a Google Drive file system with a static access token. public init(accessToken: String) { self.tokenProvider = { accessToken } } /// Creates a Google Drive file system with an authenticator that /// automatically refreshes expired tokens. public init(authenticator: GoogleDriveAuthenticator) { self.tokenProvider = { try await authenticator.validAccessToken() } } // MARK: - CloudFileSystem public func fileExists(at path: String) async throws -> Bool { let absPath = absolutePath(for: path) let parentPath = (absPath as NSString).deletingLastPathComponent let name = (absPath as NSString).lastPathComponent do { let parentID = try await resolveFolderID(forPath: parentPath) // Check for folder let folderQuery = "'\(parentID)' in parents and name='\(escapedQuery(name))' and mimeType='\(Self.folderMimeType)' and trashed=false" let folderResult = try await queryFiles(query: folderQuery, fields: "files(id)") if !folderResult.isEmpty { return true } // Check for file let fileQuery = "'\(parentID)' in parents and name='\(escapedQuery(name))' and trashed=false" let fileResult = try await queryFiles(query: fileQuery, fields: "files(id)") return !fileResult.isEmpty } catch { if isNotFoundError(error) { return false } throw error } } public func contentsOfDirectory(at path: String) async throws -> [String] { let absPath = absolutePath(for: path) let folderID: String do { folderID = try await resolveFolderID(forPath: absPath) } catch { if isNotFoundError(error) { throw CloudFileSystemError.fileNotFound } throw error } var allNames: [String] = [] var pageToken: String? = nil repeat { let query = "'\(folderID)' in parents and trashed=false" var params: [String: String] = [ "q": query, "fields": "nextPageToken,files(id,name,mimeType)", "pageSize": "1000" ] if let pageToken { params["pageToken"] = pageToken } let token = try await tokenProvider() var request = URLRequest(url: urlWithQuery(Self.apiBaseURL.appendingPathComponent("files"), params: params)) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let json = try await performRequest(request) let files = json["files"] as? [[String: Any]] ?? [] for entry in files { guard let name = entry["name"] as? String else { continue } let mimeType = entry["mimeType"] as? String ?? "" let entryPath = (absPath as NSString).appendingPathComponent(name) if mimeType == Self.folderMimeType { if let id = entry["id"] as? String { folderIDCache[entryPath] = id } // Don't include directories in the list } else { if let id = entry["id"] as? String { fileIDCache[entryPath] = id } allNames.append(name) } } pageToken = json["nextPageToken"] as? String } while pageToken != nil return allNames } public func upload(data: Data, to path: String) async throws { let absPath = absolutePath(for: path) let parentPath = (absPath as NSString).deletingLastPathComponent let fileName = (absPath as NSString).lastPathComponent // Create intermediate directories let parentID = try await createIntermediateDirectories(forPath: parentPath) // Delete existing file if present if let existingID = fileIDCache[absPath] { try? await deleteItem(withID: existingID) fileIDCache.removeValue(forKey: absPath) } else if let existingID = try? await resolveFileID(forPath: absPath) { try? await deleteItem(withID: existingID) fileIDCache.removeValue(forKey: absPath) } // Upload using multipart let metadata: [String: Any] = [ "name": fileName, "parents": [parentID] ] let metadataData = try JSONSerialization.data(withJSONObject: metadata) let boundary = UUID().uuidString let token = try await tokenProvider() var url = Self.uploadBaseURL.appendingPathComponent("files") url = urlWithQuery(url, params: ["uploadType": "multipart", "fields": "id"]) var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("multipart/related; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 3600 var body = Data() body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Type: application/json; charset=UTF-8\r\n\r\n".data(using: .utf8)!) body.append(metadataData) body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) body.append(data) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) let (responseData, response) = try await session.upload(for: request, from: body) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200..<300).contains(statusCode) else { throw mapHTTPError(statusCode: statusCode) } let json = try parseJSON(responseData) if let fileID = json["id"] as? String { fileIDCache[absPath] = fileID } } public func download(from path: String) async throws -> Data { let absPath = absolutePath(for: path) let fileID = try await resolveFileID(forPath: absPath) let token = try await tokenProvider() let url = urlWithQuery( Self.apiBaseURL.appendingPathComponent("files/\(fileID)"), params: ["alt": "media"] ) var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 3600 let (data, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200..<300).contains(statusCode) else { throw mapHTTPError(statusCode: statusCode) } return data } public func remove(at path: String) async throws { let absPath = absolutePath(for: path) // Try as file if let fileID = try? await resolveFileID(forPath: absPath) { try await deleteItem(withID: fileID) fileIDCache.removeValue(forKey: absPath) return } // 404-equivalent: not an error for remove } public func removeDirectory(at path: String) async throws { let absPath = absolutePath(for: path) // Try as folder var folderID = folderIDCache[absPath] if folderID == nil { folderID = try? await resolveFolderIDFromAPI(forPath: absPath) } if let folderID { try await deleteItem(withID: folderID) // Invalidate cache for this path and any children folderIDCache = folderIDCache.filter { !$0.key.hasPrefix(absPath) || $0.key == "/" } fileIDCache = fileIDCache.filter { !$0.key.hasPrefix(absPath) } } } // MARK: - Directory Creation /// Creates all intermediate directories for the given path, returning the ID of the deepest folder. private func createIntermediateDirectories(forPath path: String) async throws -> String { let absPath = absolutePath(for: path) let components = pathComponents(for: absPath) var currentPath = "/" var currentID = "root" for component in components { let nextPath = (currentPath as NSString).appendingPathComponent(component) if let cached = folderIDCache[nextPath] { currentID = cached currentPath = nextPath continue } // Check if folder exists let query = "'\(currentID)' in parents and name='\(escapedQuery(component))' and mimeType='\(Self.folderMimeType)' and trashed=false" let existing = try await queryFiles(query: query, fields: "files(id)") if let existingFolder = (existing.first as? [String: Any]), let existingID = existingFolder["id"] as? String { currentID = existingID folderIDCache[nextPath] = currentID currentPath = nextPath continue } // Create the folder let metadata: [String: Any] = [ "name": component, "mimeType": Self.folderMimeType, "parents": [currentID] ] let token = try await tokenProvider() var request = URLRequest(url: Self.apiBaseURL.appendingPathComponent("files")) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: metadata) let json = try await performRequest(request) if let folderID = json["id"] as? String { currentID = folderID folderIDCache[nextPath] = currentID } currentPath = nextPath } return currentID } // MARK: - Path Resolution private func resolveFolderID(forPath path: String) async throws -> String { let absPath = absolutePath(for: path) if let cached = folderIDCache[absPath] { return cached } let components = pathComponents(for: absPath) var currentPath = "/" var currentID = "root" for component in components { let nextPath = (currentPath as NSString).appendingPathComponent(component) if let cached = folderIDCache[nextPath] { currentID = cached currentPath = nextPath continue } let query = "'\(currentID)' in parents and name='\(escapedQuery(component))' and mimeType='\(Self.folderMimeType)' and trashed=false" let results = try await queryFiles(query: query, fields: "files(id,name)") guard let folder = results.first as? [String: Any], let folderID = folder["id"] as? String else { throw CloudFileSystemError.fileNotFound } currentID = folderID folderIDCache[nextPath] = currentID currentPath = nextPath } return currentID } private func resolveFolderIDFromAPI(forPath path: String) async throws -> String { let absPath = absolutePath(for: path) let parentPath = (absPath as NSString).deletingLastPathComponent let name = (absPath as NSString).lastPathComponent let parentID = try await resolveFolderID(forPath: parentPath) let query = "'\(parentID)' in parents and name='\(escapedQuery(name))' and mimeType='\(Self.folderMimeType)' and trashed=false" let results = try await queryFiles(query: query, fields: "files(id)") guard let folder = results.first as? [String: Any], let folderID = folder["id"] as? String else { throw CloudFileSystemError.fileNotFound } folderIDCache[absPath] = folderID return folderID } private func resolveFileID(forPath path: String) async throws -> String { let absPath = absolutePath(for: path) if let cached = fileIDCache[absPath] { return cached } let parentPath = (absPath as NSString).deletingLastPathComponent let fileName = (absPath as NSString).lastPathComponent let parentID = try await resolveFolderID(forPath: parentPath) let query = "'\(parentID)' in parents and name='\(escapedQuery(fileName))' and trashed=false" let results = try await queryFiles(query: query, fields: "files(id,name)") guard let file = results.first as? [String: Any], let fileID = file["id"] as? String else { throw CloudFileSystemError.fileNotFound } fileIDCache[absPath] = fileID return fileID } // MARK: - API Helpers private func queryFiles(query: String, fields: String) async throws -> [Any] { let token = try await tokenProvider() let url = urlWithQuery(Self.apiBaseURL.appendingPathComponent("files"), params: [ "q": query, "fields": fields ]) var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let json = try await performRequest(request) return json["files"] as? [Any] ?? [] } private func deleteItem(withID itemID: String) async throws { let token = try await tokenProvider() var request = URLRequest(url: Self.apiBaseURL.appendingPathComponent("files/\(itemID)")) request.httpMethod = "DELETE" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (_, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 // 204 No Content = success, 404 = already deleted guard statusCode == 204 || statusCode == 404 else { throw mapHTTPError(statusCode: statusCode) } } private func performRequest(_ request: URLRequest) async throws -> [String: Any] { var req = request req.cachePolicy = .reloadIgnoringLocalCacheData if req.timeoutInterval == 0 { req.timeoutInterval = 60 } let (data, response) = try await session.data(for: req) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200..<300).contains(statusCode) else { throw mapHTTPError(statusCode: statusCode) } return try parseJSON(data) } private func parseJSON(_ data: Data) throws -> [String: Any] { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CloudFileSystemError.serverError(statusCode: 0) } return json } // MARK: - Path Helpers func absolutePath(for path: String) -> String { var absPath = path if !absPath.hasPrefix("/") { absPath = "/" + absPath } while absPath.contains("//") { absPath = absPath.replacingOccurrences(of: "//", with: "/") } if absPath != "/", absPath.hasSuffix("/") { absPath = String(absPath.dropLast()) } return absPath } func pathComponents(for path: String) -> [String] { absolutePath(for: path).split(separator: "/").map(String.init) } private func escapedQuery(_ value: String) -> String { value.replacingOccurrences(of: "'", with: "\\'") } private func urlWithQuery(_ url: URL, params: [String: String]) -> URL { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } return components.url! } // MARK: - Error Handling private func isNotFoundError(_ error: any Error) -> Bool { if let cfsError = error as? CloudFileSystemError { if case .fileNotFound = cfsError { return true } } return false } func mapHTTPError(statusCode: Int) -> CloudFileSystemError { switch statusCode { case 401: return .authenticationFailed case 404: return .fileNotFound default: return .serverError(statusCode: statusCode) } } } ================================================ FILE: Sources/LLVSModel/Macros.swift ================================================ // // Macros.swift // LLVS // // Created by Drew McCormack on 04/03/2026. // /// Generates a `Mergeable` conformance with property-wise 3-way merge. /// /// Apply to a struct to automatically synthesize `merged(withSubordinate:commonAncestor:)` /// and `salvaging(from:)`. All stored `var` properties are merged individually; /// computed properties, static properties, and `let` constants are skipped. @attached(extension, conformances: Mergeable, names: arbitrary) public macro MergeableModel() = #externalMacro(module: "LLVSModelMacros", type: "MergeableModelMacro") ================================================ FILE: Sources/LLVSModel/Mergeable.swift ================================================ // // Mergeable.swift // LLVS // // Created by Drew McCormack on 04/03/2026. // import Foundation /// A type that supports property-wise 3-way merge. /// /// Conform to this protocol (typically via the `@MergeableModel` macro) to enable /// automatic conflict resolution in `MergeableArbiter`. public protocol Mergeable: Equatable { /// Merge `self` (dominant) with `other` (subordinate), using `commonAncestor` /// to determine which properties changed on each branch. func merged(withSubordinate other: Self, commonAncestor: Self) throws -> Self /// Resolve a conflict when no common ancestor is available (e.g. twice-inserted). /// Default: returns `self` (dominant wins). func salvaging(from other: Self) throws -> Self } public extension Mergeable { func salvaging(from other: Self) throws -> Self { self } } // MARK: - Property Merge Helpers public func mergeProperty(_ dominant: T, _ subordinate: T, _ ancestor: T) throws -> T { dominant == ancestor ? subordinate : dominant } public func mergeProperty(_ dominant: T, _ subordinate: T, _ ancestor: T) throws -> T { try dominant.merged(withSubordinate: subordinate, commonAncestor: ancestor) } public func salvageProperty(_ dominant: T, _ subordinate: T) throws -> T { dominant } public func salvageProperty(_ dominant: T, _ subordinate: T) throws -> T { try dominant.salvaging(from: subordinate) } // MARK: - Optional Mergeable extension Optional: Mergeable where Wrapped: Mergeable { public func merged(withSubordinate other: Self, commonAncestor: Self) throws -> Self { switch (self, other, commonAncestor) { case let (.some(d), .some(s), .some(a)): return try .some(d.merged(withSubordinate: s, commonAncestor: a)) case (.some(let d), .none, .some(let a)): return d == a ? other : self case (.none, _, .some): return self case (_, _, .none): return self } } public func salvaging(from other: Self) throws -> Self { switch (self, other) { case let (.some(d), .some(s)): return try .some(d.salvaging(from: s)) default: return self } } } ================================================ FILE: Sources/LLVSModel/MergeableArbiter.swift ================================================ // // MergeableArbiter.swift // LLVS // // Created by Drew McCormack on 01/03/2026. // import Foundation import LLVS /// A `MergeArbiter` that bridges LLVS merge conflicts to /// `Mergeable.merged(withSubordinate:commonAncestor:)` for property-wise resolution. /// /// Register each `StorableModel & Mergeable` type before merging. Unregistered /// value types fall through to `fallbackArbiter`. public class MergeableArbiter: MergeArbiter { /// Type-erased merge closure: `(dominant, subordinate, ancestor?) -> merged` private var registry: [String: (Data, Data, Data?) throws -> Data] = [:] /// Arbiter used for values whose `Value.ID` has no registered type prefix. public var fallbackArbiter: MergeArbiter public init(fallbackArbiter: MergeArbiter = MostRecentChangeFavoringArbiter()) { self.fallbackArbiter = fallbackArbiter } /// Register a `StorableModel & Mergeable` type so its conflicts are resolved /// with property-wise 3-way merge. public func register(_ type: T.Type) { let encoder = JSONEncoder() let decoder = JSONDecoder() registry[T.modelTypeIdentifier] = { dominantData, subordinateData, ancestorData in let dominant = try decoder.decode(T.self, from: dominantData) let subordinate = try decoder.decode(T.self, from: subordinateData) let merged: T if let ancestorData { let ancestor = try decoder.decode(T.self, from: ancestorData) merged = try dominant.merged(withSubordinate: subordinate, commonAncestor: ancestor) } else { merged = try dominant.salvaging(from: subordinate) } return try encoder.encode(merged) } } // MARK: - MergeArbiter public func changes(toResolve merge: Merge, in store: Store) throws -> [Value.Change] { // Split forks into registered (model) and unregistered (fallback) var modelForks: [Value.ID: Value.Fork] = [:] var fallbackForks: [Value.ID: Value.Fork] = [:] for (valueId, fork) in merge.forksByValueIdentifier { guard fork.isConflicting else { continue } if let typeId = modelTypeIdentifier(from: valueId), registry[typeId] != nil { modelForks[valueId] = fork } else { fallbackForks[valueId] = fork } } // Resolve model forks with Mergeable var changes: [Value.Change] = [] let v = merge.versions for (valueId, fork) in modelForks { switch fork { case .twiceUpdated: let firstValue = try store.value(id: valueId, at: v.first.id)! let secondValue = try store.value(id: valueId, at: v.second.id)! var ancestorData: Data? = nil if let ancestor = merge.commonAncestor { ancestorData = try store.value(id: valueId, at: ancestor.id)?.data } let typeId = modelTypeIdentifier(from: valueId)! let mergedData = try registry[typeId]!(firstValue.data, secondValue.data, ancestorData) changes.append(.update(Value(id: valueId, data: mergedData))) case .twiceInserted: let firstValue = try store.value(id: valueId, at: v.first.id)! let secondValue = try store.value(id: valueId, at: v.second.id)! let typeId = modelTypeIdentifier(from: valueId)! // No ancestor for twice-inserted let mergedData = try registry[typeId]!(firstValue.data, secondValue.data, nil) changes.append(.update(Value(id: valueId, data: mergedData))) case .removedAndUpdated(let removedOn): // Favor the update (preserve the non-removed branch's value) let updatedVersion = removedOn == .first ? v.second : v.first let value = try store.value(id: valueId, at: updatedVersion.id)! changes.append(.preserve(value.reference!)) default: break } } // Resolve fallback forks if !fallbackForks.isEmpty { var fallbackMerge = merge fallbackMerge.forksByValueIdentifier = fallbackForks let fallbackChanges = try fallbackArbiter.changes(toResolve: fallbackMerge, in: store) changes.append(contentsOf: fallbackChanges) } return changes } } ================================================ FILE: Sources/LLVSModel/StorableModel.swift ================================================ // // StorableModel.swift // LLVS // // Created by Drew McCormack on 01/03/2026. // import Foundation import LLVS /// A type that can be stored as a typed model value in LLVS. /// /// Conforming types get a stable `modelTypeIdentifier` (e.g. `"Contact"`) /// used as a prefix in the LLVS `Value.ID`: `"Contact/uuid-string"`. /// /// Does not require `Mergeable` — the `@MergeableModel` macro adds that /// conformance separately. public protocol StorableModel: Codable { static var modelTypeIdentifier: String { get } } // MARK: - Value.ID Helpers /// Builds an LLVS `Value.ID` from a type identifier and instance identifier. /// The format is `"TypeName/instance-id"`. public func modelValueID(typeIdentifier: String, instanceIdentifier: String) -> Value.ID { Value.ID("\(typeIdentifier)/\(instanceIdentifier)") } /// Extracts the model type identifier (prefix before the first `/`) from a `Value.ID`. /// Returns `nil` if the ID contains no `/`. public func modelTypeIdentifier(from valueID: Value.ID) -> String? { guard let slashIndex = valueID.rawValue.firstIndex(of: "/") else { return nil } return String(valueID.rawValue[.. String? { guard let slashIndex = valueID.rawValue.firstIndex(of: "/") else { return nil } return String(valueID.rawValue[valueID.rawValue.index(after: slashIndex)...]) } ================================================ FILE: Sources/LLVSModel/StoreCoordinator+Model.swift ================================================ // // StoreCoordinator+Model.swift // LLVS // // Created by Drew McCormack on 01/03/2026. // import Foundation import LLVS public extension StoreCoordinator { /// Save a model value, inserting or updating as appropriate. func save(_ model: T, instanceIdentifier: String, in branch: Branch? = nil) throws { let valueId = modelValueID(typeIdentifier: T.modelTypeIdentifier, instanceIdentifier: instanceIdentifier) let data = try JSONEncoder().encode(model) let existing = try value(id: valueId, on: branch) if existing != nil { try save(updating: [Value(id: valueId, data: data)], in: branch) } else { try save(inserting: [Value(id: valueId, data: data)], in: branch) } } /// Fetch a single model value by its instance identifier. func fetchModel(_ type: T.Type, instanceIdentifier: String, on branch: Branch? = nil) throws -> T? { let valueId = modelValueID(typeIdentifier: T.modelTypeIdentifier, instanceIdentifier: instanceIdentifier) guard let value = try value(id: valueId, on: branch) else { return nil } return try JSONDecoder().decode(T.self, from: value.data) } /// Remove a model value. func removeModel(_ type: T.Type, instanceIdentifier: String, in branch: Branch? = nil) throws { let valueId = modelValueID(typeIdentifier: T.modelTypeIdentifier, instanceIdentifier: instanceIdentifier) try save(removing: [valueId], in: branch) } /// Fetch all model values of a given type by scanning value references for matching prefixes. func fetchAllModels(_ type: T.Type, at version: Version.ID? = nil) throws -> [T] { let prefix = T.modelTypeIdentifier + "/" let refs = try valueReferences(at: version) let decoder = JSONDecoder() return try refs.compactMap { ref in guard ref.valueId.rawValue.hasPrefix(prefix) else { return nil } guard let value = try store.value(storedAt: ref) else { return nil } return try decoder.decode(T.self, from: value.data) } } } ================================================ FILE: Sources/LLVSModelMacros/MergeableModelMacro.swift ================================================ // // MergeableModelMacro.swift // LLVS // // Created by Drew McCormack on 04/03/2026. // import SwiftSyntax import SwiftSyntaxMacros enum MergeableModelMacroError: Error, CustomStringConvertible { case onlyApplicableToStruct var description: String { switch self { case .onlyApplicableToStruct: return "@MergeableModel can only be applied to a struct" } } } public struct MergeableModelMacro: ExtensionMacro { public static func expansion( of node: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, providingExtensionsOf type: some TypeSyntaxProtocol, conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { guard declaration.is(StructDeclSyntax.self) else { throw MergeableModelMacroError.onlyApplicableToStruct } let storedProperties = declaration.memberBlock.members.compactMap { member -> String? in guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { return nil } // Skip `let` constants guard varDecl.bindingSpecifier.tokenKind == .keyword(.var) else { return nil } // Skip static/class properties let isStatic = varDecl.modifiers.contains { modifier in modifier.name.tokenKind == .keyword(.static) || modifier.name.tokenKind == .keyword(.class) } guard !isStatic else { return nil } guard let binding = varDecl.bindings.first, let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { return nil } // Skip computed properties if let accessorBlock = binding.accessorBlock { switch accessorBlock.accessors { case .getter: // Shorthand computed property: `var x: T { ... }` return nil case .accessors(let accessorList): let isComputed = accessorList.contains { accessor in accessor.accessorSpecifier.tokenKind == .keyword(.get) || accessor.accessorSpecifier.tokenKind == .keyword(.set) } if isComputed { return nil } } } return pattern.identifier.text } var mergedStatements = ["var result = self"] for prop in storedProperties { mergedStatements.append("result.\(prop) = try mergeProperty(self.\(prop), other.\(prop), commonAncestor.\(prop))") } mergedStatements.append("return result") let mergedBody = mergedStatements.joined(separator: "\n ") var salvagingStatements = ["var result = self"] for prop in storedProperties { salvagingStatements.append("result.\(prop) = try salvageProperty(self.\(prop), other.\(prop))") } salvagingStatements.append("return result") let salvagingBody = salvagingStatements.joined(separator: "\n ") let extensionDecl: DeclSyntax = """ extension \(type.trimmed): LLVSModel.Mergeable { func merged(withSubordinate other: Self, commonAncestor: Self) throws -> Self { \(raw: mergedBody) } func salvaging(from other: Self) throws -> Self { \(raw: salvagingBody) } } """ return [extensionDecl.cast(ExtensionDeclSyntax.self)] } } ================================================ FILE: Sources/LLVSModelMacros/Plugin.swift ================================================ // // Plugin.swift // LLVS // // Created by Drew McCormack on 04/03/2026. // import SwiftCompilerPlugin import SwiftSyntaxMacros @main struct LLVSModelMacroPlugin: CompilerPlugin { var providingMacros: [Macro.Type] = [ MergeableModelMacro.self, ] } ================================================ FILE: Sources/LLVSOneDrive/OneDriveAuthenticator.swift ================================================ // // OneDriveAuthenticator.swift // LLVSOneDrive // // Created by Drew McCormack on 03/03/2026. // import Foundation import LLVS #if canImport(AuthenticationServices) import AuthenticationServices #endif /// Manages OAuth 2.0 authentication for Microsoft OneDrive via Microsoft Identity Platform. /// /// Handles the full OAuth 2.0 authorization code flow: opening the browser, /// exchanging the authorization code for tokens, storing tokens in the Keychain, /// and automatically refreshing expired access tokens. /// /// Interactive authorization requires `AuthenticationServices` and is available /// on iOS 16+ and macOS 13+. /// /// ```swift /// let config = OneDriveAuthenticator.Configuration( /// clientID: "your-app-client-id", /// redirectURI: "msauth.com.yourapp://auth" /// ) /// let authenticator = OneDriveAuthenticator(configuration: config) /// /// // First time: interactive authorization /// await authenticator.authorize(presenting: window) /// /// // Create file system — tokens refresh automatically /// let fs = OneDriveFileSystem(authenticator: authenticator) /// ``` public final class OneDriveAuthenticator: @unchecked Sendable { // MARK: - Configuration public struct Configuration: Sendable { /// The Application (client) ID from Azure App Registration. public let clientID: String /// The redirect URI registered in Azure App Registration. public let redirectURI: String /// OAuth 2.0 scopes. Defaults to `Files.ReadWrite` and `offline_access`. public let scopes: [String] /// The Azure AD tenant. Defaults to `common` (personal + work accounts). public let tenant: String public init( clientID: String, redirectURI: String, scopes: [String] = ["Files.ReadWrite", "offline_access"], tenant: String = "common" ) { self.clientID = clientID self.redirectURI = redirectURI self.scopes = scopes self.tenant = tenant } } // MARK: - Stored Credential private struct StoredCredential: Codable { var accessToken: String var refreshToken: String var expiresAt: Date } // MARK: - Properties public let configuration: Configuration private var authorizationURL: URL { URL(string: "https://login.microsoftonline.com/\(configuration.tenant)/oauth2/v2.0/authorize")! } private var tokenURL: URL { URL(string: "https://login.microsoftonline.com/\(configuration.tenant)/oauth2/v2.0/token")! } private var credential: StoredCredential? private var keychainService: String { "com.llvs.onedrive.\(configuration.clientID)" } private lazy var session: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 return URLSession(configuration: config) }() // MARK: - Initialization public init(configuration: Configuration) { self.configuration = configuration self.credential = loadCredentialFromKeychain() } // MARK: - Public API /// Whether the user has previously authorized (has a stored refresh token). public var isAuthorized: Bool { credential?.refreshToken != nil } /// Returns a valid access token, refreshing if expired. public func validAccessToken() async throws -> String { guard let cred = credential else { throw CloudFileSystemError.authenticationFailed } // If token is still valid (with 60-second buffer), return it if cred.expiresAt.timeIntervalSinceNow > 60 { return cred.accessToken } return try await refreshAccessToken(refreshToken: cred.refreshToken) } /// Clears stored tokens and deauthorizes. public func deauthorize() { credential = nil deleteCredentialFromKeychain() } // MARK: - Interactive Authorization #if canImport(AuthenticationServices) @MainActor public func authorize(presenting anchor: ASPresentationAnchor) async throws { let code = try await obtainAuthorizationCode(presenting: anchor) try await exchangeCodeForTokens(code) } @MainActor private func obtainAuthorizationCode(presenting anchor: ASPresentationAnchor) async throws -> String { let scope = configuration.scopes.joined(separator: " ") var components = URLComponents(url: authorizationURL, resolvingAgainstBaseURL: false)! components.queryItems = [ URLQueryItem(name: "client_id", value: configuration.clientID), URLQueryItem(name: "redirect_uri", value: configuration.redirectURI), URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "scope", value: scope), URLQueryItem(name: "prompt", value: "consent"), ] let authURL = components.url! guard let callbackScheme = URL(string: configuration.redirectURI)?.scheme else { throw CloudFileSystemError.authenticationFailed } return try await withCheckedThrowingContinuation { continuation in let anchorProvider = AnchorProvider(anchor: anchor) let session = ASWebAuthenticationSession( url: authURL, callbackURLScheme: callbackScheme ) { callbackURL, error in withExtendedLifetime(anchorProvider) {} if let error { continuation.resume(throwing: error) return } guard let callbackURL, let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { continuation.resume(throwing: CloudFileSystemError.authenticationFailed) return } continuation.resume(returning: code) } session.presentationContextProvider = anchorProvider session.prefersEphemeralWebBrowserSession = false session.start() } } private final class AnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding { let anchor: ASPresentationAnchor init(anchor: ASPresentationAnchor) { self.anchor = anchor } func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { anchor } } #endif // MARK: - Token Exchange private func exchangeCodeForTokens(_ code: String) async throws { let body = [ "code": code, "client_id": configuration.clientID, "redirect_uri": configuration.redirectURI, "grant_type": "authorization_code", "scope": configuration.scopes.joined(separator: " "), ] let tokenResponse = try await performTokenRequest(body) guard let accessToken = tokenResponse["access_token"] as? String, let refreshToken = tokenResponse["refresh_token"] as? String, let expiresIn = tokenResponse["expires_in"] as? Int else { throw CloudFileSystemError.authenticationFailed } let cred = StoredCredential( accessToken: accessToken, refreshToken: refreshToken, expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)) ) credential = cred saveCredentialToKeychain(cred) } private func refreshAccessToken(refreshToken: String) async throws -> String { let body = [ "refresh_token": refreshToken, "client_id": configuration.clientID, "grant_type": "refresh_token", "scope": configuration.scopes.joined(separator: " "), ] let tokenResponse = try await performTokenRequest(body) guard let accessToken = tokenResponse["access_token"] as? String, let expiresIn = tokenResponse["expires_in"] as? Int else { throw CloudFileSystemError.authenticationFailed } // Microsoft may return a new refresh token — use it if present let newRefreshToken = tokenResponse["refresh_token"] as? String ?? refreshToken let cred = StoredCredential( accessToken: accessToken, refreshToken: newRefreshToken, expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)) ) credential = cred saveCredentialToKeychain(cred) return accessToken } private func performTokenRequest(_ body: [String: String]) async throws -> [String: Any] { var request = URLRequest(url: tokenURL) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") let bodyString = body.map { "\($0.key)=\(urlEncode($0.value))" }.joined(separator: "&") request.httpBody = bodyString.data(using: .utf8) let (data, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200..<300).contains(statusCode) else { throw CloudFileSystemError.authenticationFailed } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CloudFileSystemError.authenticationFailed } return json } private func urlEncode(_ string: String) -> String { string.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? string } // MARK: - Keychain private func saveCredentialToKeychain(_ credential: StoredCredential) { guard let data = try? JSONEncoder().encode(credential) else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: "credential", ] SecItemDelete(query as CFDictionary) var addQuery = query addQuery[kSecValueData as String] = data SecItemAdd(addQuery as CFDictionary, nil) } private func loadCredentialFromKeychain() -> StoredCredential? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: "credential", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { return nil } return try? JSONDecoder().decode(StoredCredential.self, from: data) } private func deleteCredentialFromKeychain() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: "credential", ] SecItemDelete(query as CFDictionary) } } ================================================ FILE: Sources/LLVSOneDrive/OneDriveFileSystem.swift ================================================ // // OneDriveFileSystem.swift // LLVSOneDrive // // Created by Drew McCormack on 03/03/2026. // import Foundation import LLVS /// A `CloudFileSystem` backed by the Microsoft Graph REST API v1.0 using `URLSession`. /// /// Microsoft Graph supports native path-based addressing using the colon syntax: /// `/me/drive/root:/path/to/item:`. This means paths map directly to OneDrive paths /// without any ID resolution or caching. /// /// Two initialization paths are available: /// ```swift /// // Static access token (app manages refresh externally) /// let fs = OneDriveFileSystem(accessToken: "your-token") /// /// // Authenticator with auto-refresh /// let fs = OneDriveFileSystem(authenticator: authenticator) /// ``` public final class OneDriveFileSystem: CloudFileSystem, @unchecked Sendable { // MARK: - Properties private let tokenProvider: @Sendable () async throws -> String private static let graphBaseURL = URL(string: "https://graph.microsoft.com/v1.0")! private lazy var session: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 60 config.timeoutIntervalForResource = 3600 return URLSession(configuration: config) }() // MARK: - Initialization /// Creates a OneDrive file system with a static access token. public init(accessToken: String) { self.tokenProvider = { accessToken } } /// Creates a OneDrive file system with an authenticator that /// automatically refreshes expired tokens. public init(authenticator: OneDriveAuthenticator) { self.tokenProvider = { try await authenticator.validAccessToken() } } // MARK: - CloudFileSystem public func fileExists(at path: String) async throws -> Bool { let token = try await tokenProvider() let url = graphURL(forItemAtPath: path) var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (_, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 if statusCode == 404 { return false } if (200..<300).contains(statusCode) { return true } throw mapHTTPError(statusCode: statusCode) } public func contentsOfDirectory(at path: String) async throws -> [String] { let absPath = absolutePath(for: path) var allNames: [String] = [] var nextURL: URL? = graphURL(forChildrenAtPath: absPath) while let currentURL = nextURL { let token = try await tokenProvider() var request = URLRequest(url: currentURL) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let json = try await performRequest(request) let entries = json["value"] as? [[String: Any]] ?? [] for entry in entries { guard let name = entry["name"] as? String else { continue } // Skip folders, return only files if entry["folder"] == nil { allNames.append(name) } } // Handle pagination if let nextLink = json["@odata.nextLink"] as? String, let url = URL(string: nextLink) { nextURL = url } else { nextURL = nil } } return allNames } public func upload(data: Data, to path: String) async throws { // OneDrive auto-creates intermediate folders on PUT let token = try await tokenProvider() let url = graphURL(forContentAtPath: path) var request = URLRequest(url: url) request.httpMethod = "PUT" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 3600 let (_, response) = try await session.upload(for: request, from: data) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200..<300).contains(statusCode) else { throw mapHTTPError(statusCode: statusCode) } } public func download(from path: String) async throws -> Data { let token = try await tokenProvider() let url = graphURL(forContentAtPath: path) var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 3600 let (data, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 if statusCode == 404 { throw CloudFileSystemError.fileNotFound } guard (200..<300).contains(statusCode) else { throw mapHTTPError(statusCode: statusCode) } return data } public func remove(at path: String) async throws { let token = try await tokenProvider() let url = graphURL(forItemAtPath: path) var request = URLRequest(url: url) request.httpMethod = "DELETE" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (_, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 // 204 No Content = success, 404 = already gone guard statusCode == 204 || statusCode == 404 else { throw mapHTTPError(statusCode: statusCode) } } public func removeDirectory(at path: String) async throws { // OneDrive DELETE on a folder removes it recursively try await remove(at: path) } // MARK: - URL Construction /// Returns the Graph API URL for a drive item at the given path. /// Uses the colon syntax: `/me/drive/root:/path/to/item:` func graphURL(forItemAtPath path: String) -> URL { let absPath = absolutePath(for: path) if absPath == "/" { return Self.graphBaseURL.appendingPathComponent("me/drive/root") } let encoded = encodePathForGraph(absPath) let urlString = "\(Self.graphBaseURL.absoluteString)/me/drive/root:\(encoded):" return URL(string: urlString)! } /// Returns the Graph API URL for listing children of a directory. func graphURL(forChildrenAtPath path: String) -> URL { let absPath = absolutePath(for: path) if absPath == "/" { return Self.graphBaseURL.appendingPathComponent("me/drive/root/children") } let encoded = encodePathForGraph(absPath) let urlString = "\(Self.graphBaseURL.absoluteString)/me/drive/root:\(encoded):/children" return URL(string: urlString)! } /// Returns the Graph API URL for uploading/downloading file content. func graphURL(forContentAtPath path: String) -> URL { let absPath = absolutePath(for: path) let encoded = encodePathForGraph(absPath) let urlString = "\(Self.graphBaseURL.absoluteString)/me/drive/root:\(encoded):/content" return URL(string: urlString)! } // MARK: - Path Helpers func absolutePath(for path: String) -> String { var absPath = path if !absPath.hasPrefix("/") { absPath = "/" + absPath } while absPath.contains("//") { absPath = absPath.replacingOccurrences(of: "//", with: "/") } if absPath != "/", absPath.hasSuffix("/") { absPath = String(absPath.dropLast()) } return absPath } /// Percent-encodes a path for use in Graph API URLs. func encodePathForGraph(_ path: String) -> String { let components = path.split(separator: "/", omittingEmptySubsequences: true) let encoded = components.map { component in component.addingPercentEncoding(withAllowedCharacters: .graphPathAllowed) ?? String(component) } return "/" + encoded.joined(separator: "/") } // MARK: - Request Helpers private func performRequest(_ request: URLRequest) async throws -> [String: Any] { var req = request req.cachePolicy = .reloadIgnoringLocalCacheData if req.timeoutInterval == 0 { req.timeoutInterval = 60 } let (data, response) = try await session.data(for: req) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 if statusCode == 404 { throw CloudFileSystemError.fileNotFound } guard (200..<300).contains(statusCode) else { throw mapHTTPError(statusCode: statusCode) } return try parseJSON(data) } private func parseJSON(_ data: Data) throws -> [String: Any] { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CloudFileSystemError.serverError(statusCode: 0) } return json } // MARK: - Error Handling func mapHTTPError(statusCode: Int) -> CloudFileSystemError { switch statusCode { case 401: return .authenticationFailed case 404: return .fileNotFound default: return .serverError(statusCode: statusCode) } } } // MARK: - Character Set Extension private extension CharacterSet { /// Characters allowed in OneDrive path components. /// Standard URL path-allowed characters minus colon, hash, and question mark /// (colon is used as the Graph API delimiter). static let graphPathAllowed: CharacterSet = { var cs = CharacterSet.urlPathAllowed cs.remove(charactersIn: ":#?") return cs }() } ================================================ FILE: Sources/LLVSPCloud/PCloudExchange.swift ================================================ // // PCloudExchange.swift // LLVS // // Created by Drew McCormack on 28/02/2026. // import Foundation import LLVS import PCloudSDKSwift /// An Exchange that syncs versions via the pCloud Swift SDK. /// /// Data is stored in a named folder on pCloud, organized as: /// - `versions/` — JSON-encoded version metadata, one file per version /// - `changes/` — JSON-encoded value changes, one file per version /// /// Uses `PCloudClient` from the official pCloud SDK for all API operations. /// Folder IDs are cached via `restorationState` to avoid repeated lookups. public class PCloudExchange: FolderBasedExchange { public typealias FileID = UInt64 public typealias FolderID = UInt64 public enum Error: Swift.Error { case missingDownloadLink case invalidResponse } public let store: Store /// The pCloud client used for API calls. Create via `PCloud.createClient(with:hostProvider:)`. public let client: PCloudClient /// Parent folder ID on pCloud where the LLVS folder will be created. 0 = root. public let parentFolderID: UInt64 /// Name of the folder on pCloud that holds LLVS data. public let folderName: String /// URL session used for downloading file data from pCloud CDN links. private let urlSession: URLSession @Atomic private var restoration = RestorationInfo() public let newVersionsAvailable: AsyncStream private let newVersionsContinuation: AsyncStream.Continuation public var restorationState: Data? { get { try? JSONEncoder().encode(restoration) } set { if let data = newValue, let info = try? JSONDecoder().decode(RestorationInfo.self, from: data) { restoration = info } } } /// - Parameters: /// - store: The LLVS store to sync. /// - client: A `PCloudClient` instance, created via `PCloud.createClient(with:hostProvider:)`. /// - parentFolderID: pCloud folder ID in which to create the LLVS folder. Defaults to 0 (root). /// - folderName: Name of the folder to create for LLVS data. Defaults to "LLVS". /// - urlSession: URLSession for downloading file content from CDN links. Defaults to `.shared`. public init(store: Store, client: PCloudClient, parentFolderID: UInt64 = 0, folderName: String = "LLVS", urlSession: URLSession = .shared) { self.store = store self.client = client self.parentFolderID = parentFolderID self.folderName = folderName self.urlSession = urlSession let (stream, continuation) = AsyncStream.makeStream() self.newVersionsAvailable = stream self.newVersionsContinuation = continuation } // MARK: - Prepare public func prepareToRetrieve() async throws { try await ensureFoldersExist() } public func prepareToSend() async throws { try await ensureFoldersExist() } // MARK: - FolderBasedExchange public var versionsFolderID: UInt64? { restoration.versionsFolderID } public var changesFolderID: UInt64? { restoration.changesFolderID } public func notifyNewVersionsAvailable() { newVersionsContinuation.yield(()) } public func listFiles(inFolder folderID: UInt64) async throws -> [String: UInt64] { try await withCheckedThrowingContinuation { continuation in client.listFolder(folderID, recursively: false) .addCompletionBlock { result in switch result { case .success(let folder): var fileMap: [String: UInt64] = [:] for content in folder.contents { if case .file(let file) = content { fileMap[file.name] = file.id } } continuation.resume(returning: fileMap) case .failure(let error): continuation.resume(throwing: error) } } .start() } } public func downloadData(forFile fileID: UInt64) async throws -> Data { let link: URL = try await withCheckedThrowingContinuation { continuation in client.getFileLink(forFile: fileID) .addCompletionBlock { result in switch result { case .success(let links): guard let link = links.first else { continuation.resume(throwing: Error.missingDownloadLink) return } continuation.resume(returning: link.address) case .failure(let error): continuation.resume(throwing: error) } } .start() } return try await withCheckedThrowingContinuation { continuation in let task = urlSession.dataTask(with: link) { data, _, error in if let error = error { continuation.resume(throwing: error) } else if let data = data { continuation.resume(returning: data) } else { continuation.resume(throwing: Error.invalidResponse) } } task.resume() } } public func uploadData(_ data: Data, named name: String, toFolder folderID: UInt64) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in client.upload(data, toFolder: folderID, asFileNamed: name) .addCompletionBlock { result in switch result { case .success: continuation.resume(returning: ()) case .failure(let error): continuation.resume(throwing: error) } } .start() } } // MARK: - pCloud SDK Helpers private func ensureFoldersExist() async throws { if restoration.versionsFolderID != nil && restoration.changesFolderID != nil { return } let rootID = try await createFolderIfNeeded(named: folderName, inFolder: parentFolderID) restoration.rootFolderID = rootID let versionsID = try await createFolderIfNeeded(named: "versions", inFolder: rootID) restoration.versionsFolderID = versionsID let changesID = try await createFolderIfNeeded(named: "changes", inFolder: rootID) restoration.changesFolderID = changesID } private func createFolderIfNeeded(named name: String, inFolder parentID: UInt64) async throws -> UInt64 { try await withCheckedThrowingContinuation { continuation in client.createFolderIfDoesNotExist(named: name, inFolder: parentID) .addCompletionBlock { result in switch result { case .success(let response): continuation.resume(returning: response.folder.id) case .failure(let error): continuation.resume(throwing: error) } } .start() } } // MARK: - Restoration fileprivate struct RestorationInfo: Codable { var rootFolderID: UInt64? var versionsFolderID: UInt64? var changesFolderID: UInt64? } } ================================================ FILE: Sources/LLVSSQLite/SQLiteDatabase+Zones.swift ================================================ // // SQLiteDatabase+Zones.swift // // // Created by Drew McCormack on 16/02/2022. // import Foundation import LLVS internal extension SQLiteDatabase { func setupForZone() throws { try execute(statement: """ CREATE TABLE IF NOT EXISTS Zone( key TEXT NOT NULL, version TEXT NOT NULL, data BLOB NOT NULL, PRIMARY KEY (key, version) ); """ ) try execute(statement: """ CREATE INDEX IF NOT EXISTS index_key ON Zone (key); """ ) try execute(statement: """ CREATE INDEX IF NOT EXISTS index_version ON Zone (version); """ ) } func store(_ data: Data, for reference: ZoneReference) throws { try execute(statement: """ INSERT OR REPLACE INTO Zone (key, version, data) VALUES (?, ?, ?); """, withBindingsList: [[reference.key, reference.version.rawValue, data]]) } func data(for reference: ZoneReference) throws -> Data? { var result: Data? try forEach(matchingQuery: """ SELECT data FROM Zone WHERE key=? AND version=?; """, withBindings: [reference.key, reference.version.rawValue]) { row in result = row.value(inColumnAtIndex: 0) } return result } func data(forReferences references: [(key: String, version: String)]) throws -> [Int: Data] { guard !references.isEmpty else { return [:] } // Group references by version, tracking original indices var indicesByVersion: [String: [(index: Int, key: String)]] = [:] for (i, ref) in references.enumerated() { indicesByVersion[ref.version, default: []].append((index: i, key: ref.key)) } var result: [Int: Data] = [:] // One query per distinct version for (version, entries) in indicesByVersion { let placeholders = entries.map { _ in "?" }.joined(separator: ", ") let query = "SELECT key, data FROM Zone WHERE version = ? AND key IN (\(placeholders));" var bindings: [Any?] = [version] bindings.append(contentsOf: entries.map { $0.key as Any? }) // Build lookup from key to indices (multiple refs can share a key within the same version) var indexByKey: [String: [Int]] = [:] for entry in entries { indexByKey[entry.key, default: []].append(entry.index) } try forEach(matchingQuery: query, withBindings: bindings) { row in let key: String = row.value(inColumnAtIndex: 0)! let data: Data = row.value(inColumnAtIndex: 1)! if let indices = indexByKey[key] { for index in indices { result[index] = data } } } } return result } func versionIds(forKey key: String) throws -> [String] { var versionStrings: [String] = [] try forEach(matchingQuery: """ SELECT DISTINCT version FROM Zone WHERE key=?; """, withBindings: [key]) { row in let versionId: String = row.value(inColumnAtIndex: 0)! versionStrings.append(versionId) } return versionStrings } } ================================================ FILE: Sources/LLVSSQLite/SQLiteDatabase.swift ================================================ // // Database.swift // LLVS // // Created by Drew McCormack on 19/01/2017. // import Foundation import SQLite3 // Used for SQLite cleanup internal let SQLITE_STATIC = unsafeBitCast(0, to: sqlite3_destructor_type.self) internal let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) /// Thin wrapper around SQLite3. Allows creating, updating, and querying a SQLite3 database /// with as little fuss as possible. /// This class is not thread-safe. It is up to the /// user of the class to ensure it is only accessed from one thread at a time (e.g. using /// a serial queue). The decision not to use an internal queue is deliberate: /// this gives the caller complete freedom in how the database is accessed, rather than /// imposing a particular solution (e.g. GCD, OperationQueue, Actor). It keeps database /// access independent of the preferred concurrency model used in the app. public final class SQLiteDatabase { /// SQLite Errors public enum Error: Swift.Error { case openFailed(code: Int32) case closeFailed(code: Int32) case statementFailed(statement: String, code: Int32) case bindingFailed(bindingIndex: Int, value: Any?, code: Int32) case queryFailed(query: String, code: Int32) } /// Location of the database file public let fileURL: URL /// Pointer to the SQLite database object private var database: OpaquePointer! /// The last error message from the database public var errorMessage: String? { return String(cString: sqlite3_errmsg(database)) } // MARK: Creating and Deleting /// Open or create a database at the file URL passed in public init(fileURL: URL) throws { self.fileURL = fileURL var newDatabase: OpaquePointer? let code = sqlite3_open(fileURL.path, &newDatabase) guard code == SQLITE_OK else { throw Error.openFailed(code: code) } database = newDatabase! } deinit { do { try close() } catch { print("Could not close database: \(error)") } } // MARK: Executing SQL /// Explicitly close the database. If you don't call this, it will be called in deinit. public func close() throws { guard let database = database else { return } let code = sqlite3_close(database) if code != SQLITE_OK { throw Error.closeFailed(code: code) } self.database = nil } /// Execute a SQLite statement with bindings provided by an array of arrays. /// Each entry in the outer array is an array holding the bindings for one statement. /// The statement will be executed multiple times with different values. /// Passing nothing for the bindings can be used to execute a statement that has no bound values. /// Passing an empty array for the bindings is allowed, but is effectively a no-op. public func execute(statement: String, withBindingsList bindings: [[Any?]] = [[]]) throws { var sqlStatement: OpaquePointer? let prepareCode = sqlite3_prepare_v2(database, statement, -1, &sqlStatement, nil) guard prepareCode == SQLITE_OK else { throw Error.statementFailed(statement: statement, code: prepareCode) } defer { finalize(sqlStatement!) } for boundValues in bindings { try bind(values: boundValues, toSQLStatement: sqlStatement!) let stepCode = sqlite3_step(sqlStatement) guard stepCode == SQLITE_DONE else { throw Error.statementFailed(statement: statement, code: stepCode) } let resetCode = sqlite3_reset(sqlStatement) guard resetCode == SQLITE_OK else { throw Error.statementFailed(statement: statement, code: resetCode) } } } /// Fetch with a query, and iterate the results in the row handler. /// The row should not be allowed to escape the row handler block, and must remain on the same thread. /// In general, you should extract the data from the `Row` directly, and once that is done, you can treat /// the resulting data as you please. public func forEach(matchingQuery query: String, withBindings bindings: [Any?] = [], rowHandler: (Row) throws ->Void ) throws { var sqlStatement: OpaquePointer? let prepareCode = sqlite3_prepare_v2(database, query, -1, &sqlStatement, nil) guard prepareCode == SQLITE_OK else { throw Error.queryFailed(query: query, code: prepareCode) } defer { finalize(sqlStatement!) } try bind(values: bindings, toSQLStatement: sqlStatement!) while sqlite3_step(sqlStatement) == SQLITE_ROW { let row = Row(statement: sqlStatement!) try rowHandler(row) } } /// Finalizes the statement. This func does not throw, because it is often called /// in a defer block, where errors go unhandled. private func finalize(_ sqlStatement: OpaquePointer) { let finalizeCode = sqlite3_finalize(sqlStatement) if finalizeCode != SQLITE_OK { print("Failed to finalize statement") } } /// Binds the passed values to the corresponding placeholders in the compiled SQL statement. private func bind(values: [Any?], toSQLStatement sqlStatement: OpaquePointer) throws { for (i, value) in values.enumerated() { let bindIndex = Int32(i + 1) var code = SQLITE_OK switch value { case nil: code = sqlite3_bind_null(sqlStatement, bindIndex) case let text? as String?: let utf8String = text.cString(using: .utf8) code = sqlite3_bind_text(sqlStatement, bindIndex, utf8String, -1, SQLITE_TRANSIENT) case let data? as Data?: let bytes = [UInt8](data) code = sqlite3_bind_blob(sqlStatement, bindIndex, bytes, Int32(data.count), SQLITE_TRANSIENT) case let intValue? as Int32?: code = sqlite3_bind_int(sqlStatement, bindIndex, intValue) case let intValue? as Int64?: code = sqlite3_bind_int64(sqlStatement, bindIndex, intValue) case let doubleValue? as Double?: code = sqlite3_bind_double(sqlStatement, bindIndex, doubleValue) default: preconditionFailure("Invalid type") } guard code == SQLITE_OK else { throw Error.bindingFailed(bindingIndex: i+1, value: value, code: code) } } } // MARK: Row /// Row struct used in fetches public struct Row { private let statement: OpaquePointer fileprivate init(statement: OpaquePointer) { self.statement = statement } /// Retrieve a value from the database, in the column passed. /// Only SQLite types are supported (Int32, Int64, Double, String, Data), /// as well as optional variants. Column indexes are zero based. public func value(inColumnAtIndex column: Int) -> T? { if sqlite3_column_type(statement, 0) == SQLITE_NULL { return nil } switch T.self { case is String.Type: let cString = sqlite3_column_text(statement, Int32(column)) return String(cString: cString!) as? T case is Data.Type: let length = sqlite3_column_bytes(statement, Int32(column)) let bytes = sqlite3_column_blob(statement, Int32(column)) let data = Data(bytes: bytes!, count: Int(length)) return data as? T case is Int64.Type: return sqlite3_column_int64(statement, Int32(column)) as? T case is Int32.Type: return Int32(sqlite3_column_int(statement, Int32(column))) as? T case is Double.Type: return sqlite3_column_double(statement, Int32(column)) as? T default: preconditionFailure("Unsupported type") } } } } ================================================ FILE: Sources/LLVSSQLite/SQLiteZone.swift ================================================ // // SQLiteZone.swift // LLVS // // Created by Drew McCormack on 14/05/2019. // import Foundation import LLVS import SQLite3 public class SQLiteStorage: Storage, SnapshotCapable { private let fileExtension = "sqlite" public init() {} public func makeMapZone(for type: MapType, in store: Store) throws -> Zone { switch type { case .valuesByVersion: return try SQLiteZone(rootDirectory: store.valuesMapDirectoryURL, fileExtension: fileExtension) case .userDefined: fatalError("User defined maps not yet supported") } } public func makeValuesZone(in store: Store) throws -> Zone { return try SQLiteZone(rootDirectory: store.valuesDirectoryURL, fileExtension: fileExtension) } } internal final class SQLiteZone: Zone { let rootDirectory: URL let fileExtension: String private let fileURL: URL private let database: SQLiteDatabase private let uncachableDataSizeLimit = 10000 // 10KB private let cache: Cache = .init() fileprivate let fileManager = FileManager() init(rootDirectory: URL, fileExtension: String) throws { let resolvedURL = rootDirectory.resolvingSymlinksInPath() self.rootDirectory = resolvedURL try? fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true, attributes: nil) self.fileExtension = fileExtension self.fileURL = resolvedURL.appendingPathComponent("zone").appendingPathExtension(fileExtension) database = try SQLiteDatabase(fileURL: self.fileURL) try database.setupForZone() } internal func dismantle() throws { try database.close() } private func cacheIfNeeded(_ data: Data, for reference: ZoneReference) { if data.count < uncachableDataSizeLimit { cache.setValue(data, for: reference) } } internal func store(_ data: Data, for reference: ZoneReference) throws { let compressed = DataCompression.compress(data) try database.store(compressed, for: reference) cacheIfNeeded(data, for: reference) } internal func data(for reference: ZoneReference) throws -> Data? { if let data = cache.value(for: reference) { return data } guard let raw = try database.data(for: reference) else { return nil } let data = DataCompression.decompressIfNeeded(raw) cacheIfNeeded(data, for: reference) return data } internal func data(for references: [ZoneReference]) throws -> [Data?] { guard !references.isEmpty else { return [] } // Check cache first, collect uncached references with their indices var results = [Data?](repeating: nil, count: references.count) var uncached: [(index: Int, key: String, version: String)] = [] for (i, ref) in references.enumerated() { if let data = cache.value(for: ref) { results[i] = data } else { uncached.append((index: i, key: ref.key, version: ref.version.rawValue)) } } if !uncached.isEmpty { let tuples = uncached.map { (key: $0.key, version: $0.version) } let fetched = try database.data(forReferences: tuples) for (localIndex, entry) in uncached.enumerated() { if let raw = fetched[localIndex] { let data = DataCompression.decompressIfNeeded(raw) results[entry.index] = data cacheIfNeeded(data, for: references[entry.index]) } } } return results } internal func versionIds(for key: String) throws -> [Version.ID] { try database.versionIds(forKey: key).map { Version.ID($0) } } } ================================================ FILE: Sources/LLVSWebDAV/WebDAVFileSystem.swift ================================================ // // WebDAVFileSystem.swift // LLVSWebDAV // // Created by Drew McCormack on 03/03/2026. // import Foundation import LLVS /// A `CloudFileSystem` backed by a WebDAV server using `URLSession`. /// /// No third-party dependencies — uses standard HTTP methods /// (PROPFIND, MKCOL, PUT, GET, DELETE). /// /// Authentication is handled via `URLCredential`, supporting both Basic and /// Digest auth (Digest is handled automatically by URLSession's challenge mechanism). public final class WebDAVFileSystem: CloudFileSystem, @unchecked Sendable { // MARK: - Properties /// The base URL of the WebDAV server. public let baseURL: URL /// The credential used for authentication. public var credential: URLCredential? private lazy var session: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 60 config.timeoutIntervalForResource = 3600 let delegate = SessionDelegate(fileSystem: self) return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) }() // MARK: - Initialization /// Creates a WebDAV file system with the given base URL. /// - Parameters: /// - baseURL: The root URL of the WebDAV server. /// - username: Optional username for authentication. /// - password: Optional password for authentication. public init(baseURL: URL, username: String? = nil, password: String? = nil) { self.baseURL = baseURL if let username, let password { self.credential = URLCredential(user: username, password: password, persistence: .forSession) } } // MARK: - CloudFileSystem public func fileExists(at path: String) async throws -> Bool { let request = makePropfindRequest(forPath: path, depth: 0) let (_, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 if statusCode == 404 { return false } try checkHTTPResponse(statusCode: statusCode) return true } public func contentsOfDirectory(at path: String) async throws -> [String] { var dirPath = path if !dirPath.hasSuffix("/") { dirPath += "/" } let request = makePropfindRequest(forPath: dirPath, depth: 1) let (data, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 if statusCode == 404 { throw CloudFileSystemError.fileNotFound } try checkHTTPResponse(statusCode: statusCode) let parser = WebDAVResponseParser(data: data) try parser.parse() // The first item is the directory itself — skip it var items = parser.parsedItems if items.count > 1 { items = Array(items.dropFirst()) } else { items = [] } // Return only file names (not directories) return items.filter { !$0.isDirectory }.map { $0.name } } public func upload(data: Data, to path: String) async throws { // Create intermediate directories try await createIntermediateDirectories(for: path) var request = makeRequest(forPath: path) request.httpMethod = "PUT" request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length") request.timeoutInterval = 3600 let (_, response) = try await session.upload(for: request, from: data) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 try checkHTTPResponse(statusCode: statusCode) } public func download(from path: String) async throws -> Data { var request = makeRequest(forPath: path) request.httpMethod = "GET" let (data, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 if statusCode == 404 { throw CloudFileSystemError.fileNotFound } try checkHTTPResponse(statusCode: statusCode) return data } public func remove(at path: String) async throws { var request = makeRequest(forPath: path) request.httpMethod = "DELETE" let (_, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 // 404 means already gone — not an error if statusCode == 404 { return } try checkHTTPResponse(statusCode: statusCode) } public func removeDirectory(at path: String) async throws { // WebDAV DELETE on a collection removes it recursively var dirPath = path if !dirPath.hasSuffix("/") { dirPath += "/" } var request = makeRequest(forPath: dirPath) request.httpMethod = "DELETE" let (_, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 // 404 means already gone — not an error if statusCode == 404 { return } try checkHTTPResponse(statusCode: statusCode) } // MARK: - Directory Creation private func createIntermediateDirectories(for path: String) async throws { let components = path.split(separator: "/").dropLast() // drop the filename var currentPath = "" for component in components { currentPath += "/\(component)" // Check if directory exists let request = makePropfindRequest(forPath: currentPath + "/", depth: 0) let (_, response) = try await session.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 if statusCode == 404 { // Create the directory var mkcolRequest = makeRequest(forPath: currentPath) mkcolRequest.httpMethod = "MKCOL" mkcolRequest.setValue("application/xml", forHTTPHeaderField: "Content-Type") let (_, mkcolResponse) = try await session.data(for: mkcolRequest) let mkcolStatus = (mkcolResponse as? HTTPURLResponse)?.statusCode ?? 0 // 405 Method Not Allowed means the directory already exists if mkcolStatus != 405 { try checkHTTPResponse(statusCode: mkcolStatus) } } } } // MARK: - Request Building private func makeRequest(forPath path: String) -> URLRequest { let url = baseURL.appendingPathComponent(path) return URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 60) } private func makePropfindRequest(forPath path: String, depth: Int) -> URLRequest { var request = makeRequest(forPath: path) request.httpMethod = "PROPFIND" request.setValue("\(depth)", forHTTPHeaderField: "Depth") request.setValue("application/xml", forHTTPHeaderField: "Content-Type") let xml = """ \ \ \ \ \ \ \ """ request.httpBody = xml.data(using: .utf8) return request } // MARK: - Response Handling private func checkHTTPResponse(statusCode: Int) throws { if statusCode == 401 { throw CloudFileSystemError.authenticationFailed } guard (200..<300).contains(statusCode) || statusCode == 207 else { throw CloudFileSystemError.serverError(statusCode: statusCode) } } } // MARK: - URLSession Delegate for Authentication private final class SessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate, @unchecked Sendable { weak var fileSystem: WebDAVFileSystem? init(fileSystem: WebDAVFileSystem) { self.fileSystem = fileSystem } func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let protectionSpace = challenge.protectionSpace if protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust || protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { completionHandler(.performDefaultHandling, nil) return } if let credential = fileSystem?.credential, challenge.previousFailureCount == 0 { completionHandler(.useCredential, credential) } else { completionHandler(.performDefaultHandling, nil) } } } ================================================ FILE: Sources/LLVSWebDAV/WebDAVResponseParser.swift ================================================ // // WebDAVResponseParser.swift // LLVSWebDAV // // Created by Drew McCormack on 03/03/2026. // import Foundation import LLVS /// Parses PROPFIND XML responses from a WebDAV server. /// Handles namespace variations (`D:`, `lp1:`, etc.) for broad server compatibility. final class WebDAVResponseParser: NSObject, XMLParserDelegate, @unchecked Sendable { struct Item { let name: String let isDirectory: Bool } private let xmlParser: XMLParser private var items: [Item] = [] private var characters = "" private var currentItemDictionary: [String: Any]? var parsedItems: [Item] { items } init(data: Data) { xmlParser = XMLParser(data: data) super.init() xmlParser.delegate = self } func parse() throws { let success = xmlParser.parse() if !success { throw xmlParser.parserError ?? CloudFileSystemError.serverError(statusCode: 0) } } // MARK: - Element Matching /// Matches element names with or without common WebDAV namespace prefixes. private func element(_ element: String, matches other: String) -> Bool { if element.caseInsensitiveCompare(other) == .orderedSame { return true } if ("D:" + element).caseInsensitiveCompare(other) == .orderedSame { return true } if ("lp1:" + element).caseInsensitiveCompare(other) == .orderedSame { return true } return false } // MARK: - XMLParserDelegate func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { characters = "" if element(elementName, matches: "D:response") { currentItemDictionary = [:] } } func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { if element(elementName, matches: "D:href") { currentItemDictionary?["path"] = characters.removingPercentEncoding ?? characters } else if element(elementName, matches: "D:collection") { currentItemDictionary?["isDirectory"] = true } else if element(elementName, matches: "D:response"), let dict = currentItemDictionary, let path = dict["path"] as? String { let isDir = dict["isDirectory"] as? Bool ?? false let name = (path as NSString).lastPathComponent items.append(Item(name: name, isDirectory: isDir)) currentItemDictionary = nil } } func parser(_ parser: XMLParser, foundCharacters string: String) { characters += string } } ================================================ FILE: Sources/SQLite3/module.modulemap ================================================ module SQLite { header "shim.h" link "sqlite3" export * } ================================================ FILE: Sources/SQLite3/shim.h ================================================ #include ================================================ FILE: Tests/LLVSModelTests/MergeableArbiterTests.swift ================================================ // // MergeableArbiterTests.swift // LLVSModelTests // // Created by Drew McCormack on 01/03/2026. // import Testing import Foundation @testable import LLVS @testable import LLVSModel // MARK: - Test Model Types @MergeableModel struct Contact: StorableModel, Equatable { static let modelTypeIdentifier = "Contact" var name: String = "" var notes: String = "" var age: Int = 0 } @MergeableModel struct Tag: StorableModel, Equatable { static let modelTypeIdentifier = "Tag" var label: String = "" } @MergeableModel struct InnerModel: Codable, Equatable { var x: Int = 0 var y: Int = 0 } @MergeableModel struct OuterModel: StorableModel, Equatable { static let modelTypeIdentifier = "OuterModel" var inner: InnerModel = InnerModel() var label: String = "" } @MergeableModel struct OuterOptionalModel: StorableModel, Equatable { static let modelTypeIdentifier = "OuterOptionalModel" var inner: InnerModel? = nil var label: String = "" } // MARK: - Tests @Suite class MergeableArbiterTests { let store: Store let rootURL: URL init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store = try Store(rootDirectoryURL: rootURL) } deinit { try? FileManager.default.removeItem(at: rootURL) } // MARK: - Helpers private func makeVersion(basedOn predecessor: Version.ID?, changes: [Value.Change]) -> Version { try! store.makeVersion(basedOnPredecessor: predecessor, storing: changes) } private func encode(_ value: T) -> Data { try! JSONEncoder().encode(value) } // MARK: - Property-wise 3-way Merge @Test func threeWayMergeResolvesPropertyWise() throws { let arbiter = MergeableArbiter() arbiter.register(Contact.self) let valueId = modelValueID(typeIdentifier: "Contact", instanceIdentifier: "abc") // Ancestor: name="Alice", notes="Hello", age=30 let ancestor = Contact(name: "Alice", notes: "Hello", age: 30) let v0 = makeVersion(basedOn: nil, changes: [.insert(Value(id: valueId, data: encode(ancestor)))]) // Branch 1: change name to "Bob" var branch1Contact = ancestor branch1Contact.name = "Bob" let v1 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(branch1Contact)))]) // Branch 2: change age to 31 var branch2Contact = ancestor branch2Contact.age = 31 let v2 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(branch2Contact)))]) // Merge let merged = try store.mergeRelated(version: v1.id, with: v2.id, resolvingWith: arbiter) let result = try store.value(id: valueId, at: merged.id)! let contact = try JSONDecoder().decode(Contact.self, from: result.data) #expect(contact.name == "Bob") #expect(contact.age == 31) #expect(contact.notes == "Hello") } // MARK: - Twice Inserted (salvaging) @Test func twiceInsertedUsesSalvaging() throws { let arbiter = MergeableArbiter() arbiter.register(Contact.self) let valueId = modelValueID(typeIdentifier: "Contact", instanceIdentifier: "new") // Two branches independently insert the same key (no common ancestor with that value) let v0 = makeVersion(basedOn: nil, changes: []) let c1 = Contact(name: "Alice", notes: "", age: 25) let v1 = makeVersion(basedOn: v0.id, changes: [.insert(Value(id: valueId, data: encode(c1)))]) let c2 = Contact(name: "Bob", notes: "", age: 30) let v2 = makeVersion(basedOn: v0.id, changes: [.insert(Value(id: valueId, data: encode(c2)))]) let merged = try store.mergeRelated(version: v1.id, with: v2.id, resolvingWith: arbiter) let result = try store.value(id: valueId, at: merged.id)! let contact = try JSONDecoder().decode(Contact.self, from: result.data) // Default salvaging returns self (dominant/first), so first branch wins #expect(contact.name == "Alice") } // MARK: - Removed and Updated @Test func removedAndUpdatedFavorsUpdate() throws { let arbiter = MergeableArbiter() arbiter.register(Contact.self) let valueId = modelValueID(typeIdentifier: "Contact", instanceIdentifier: "ru") let ancestor = Contact(name: "Alice", notes: "", age: 30) let v0 = makeVersion(basedOn: nil, changes: [.insert(Value(id: valueId, data: encode(ancestor)))]) // Branch 1: remove let v1 = makeVersion(basedOn: v0.id, changes: [.remove(valueId)]) // Branch 2: update var updated = ancestor updated.name = "Bob" let v2 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(updated)))]) let merged = try store.mergeRelated(version: v1.id, with: v2.id, resolvingWith: arbiter) let result = try store.value(id: valueId, at: merged.id) #expect(result != nil, "Value should be preserved (update wins over remove)") let contact = try JSONDecoder().decode(Contact.self, from: result!.data) #expect(contact.name == "Bob") } // MARK: - Fallback Arbiter @Test func unregisteredTypeFallsBackToDefaultArbiter() throws { let arbiter = MergeableArbiter() // Intentionally do NOT register any types let valueId = Value.ID("untyped/thing") let v0 = makeVersion(basedOn: nil, changes: [.insert(Value(id: valueId, data: "old".data(using: .utf8)!))]) let v1 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: "branch1".data(using: .utf8)!))]) let v2 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: "branch2".data(using: .utf8)!))]) // Should not throw — fallback arbiter resolves it let merged = try store.mergeRelated(version: v1.id, with: v2.id, resolvingWith: arbiter) let result = try store.value(id: valueId, at: merged.id) #expect(result != nil) } // MARK: - StoreCoordinator Typed Save/Fetch @Test func storeCoordinatorSaveAndFetchRoundTrip() throws { let cacheURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) let coordinator = try StoreCoordinator(withStoreDirectoryAt: rootURL, cacheDirectoryAt: cacheURL) defer { try? FileManager.default.removeItem(at: cacheURL) } let contact = Contact(name: "Alice", notes: "Met at WWDC", age: 30) try coordinator.save(contact, instanceIdentifier: "abc") let fetched: Contact? = try coordinator.fetchModel(Contact.self, instanceIdentifier: "abc") #expect(fetched?.name == "Alice") #expect(fetched?.notes == "Met at WWDC") #expect(fetched?.age == 30) } // MARK: - fetchAllModels @Test func fetchAllModelsReturnsCorrectTypeSubset() throws { let cacheURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) let coordinator = try StoreCoordinator(withStoreDirectoryAt: rootURL, cacheDirectoryAt: cacheURL) defer { try? FileManager.default.removeItem(at: cacheURL) } try coordinator.save(Contact(name: "Alice", notes: "", age: 30), instanceIdentifier: "a") try coordinator.save(Contact(name: "Bob", notes: "", age: 25), instanceIdentifier: "b") try coordinator.save(Tag(label: "VIP"), instanceIdentifier: "t1") let contacts: [Contact] = try coordinator.fetchAllModels(Contact.self) #expect(contacts.count == 2) let tags: [Tag] = try coordinator.fetchAllModels(Tag.self) #expect(tags.count == 1) #expect(tags.first?.label == "VIP") } // MARK: - Value.ID Helpers @Test func modelValueIDHelpers() { let valueId = modelValueID(typeIdentifier: "Contact", instanceIdentifier: "abc-123") #expect(valueId.rawValue == "Contact/abc-123") #expect(modelTypeIdentifier(from: valueId) == "Contact") #expect(instanceIdentifier(from: valueId) == "abc-123") } @Test func helperReturnsNilForNoSlash() { let valueId = Value.ID("noslash") #expect(modelTypeIdentifier(from: valueId) == nil) #expect(instanceIdentifier(from: valueId) == nil) } // MARK: - Nested Mergeable @Test func nestedMergeablePreservesBothBranchChanges() throws { let arbiter = MergeableArbiter() arbiter.register(OuterModel.self) let valueId = modelValueID(typeIdentifier: "OuterModel", instanceIdentifier: "o1") let ancestor = OuterModel(inner: InnerModel(x: 1, y: 2), label: "start") let v0 = makeVersion(basedOn: nil, changes: [.insert(Value(id: valueId, data: encode(ancestor)))]) // Branch 1: change inner.x var b1 = ancestor b1.inner.x = 10 let v1 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(b1)))]) // Branch 2: change inner.y var b2 = ancestor b2.inner.y = 20 let v2 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(b2)))]) let merged = try store.mergeRelated(version: v1.id, with: v2.id, resolvingWith: arbiter) let result = try store.value(id: valueId, at: merged.id)! let outer = try JSONDecoder().decode(OuterModel.self, from: result.data) #expect(outer.inner.x == 10) #expect(outer.inner.y == 20) #expect(outer.label == "start") } // MARK: - Optional Mergeable @Test func optionalMergeableBothSomePreservesSubproperties() throws { let arbiter = MergeableArbiter() arbiter.register(OuterOptionalModel.self) let valueId = modelValueID(typeIdentifier: "OuterOptionalModel", instanceIdentifier: "om1") let ancestor = OuterOptionalModel(inner: InnerModel(x: 1, y: 2), label: "start") let v0 = makeVersion(basedOn: nil, changes: [.insert(Value(id: valueId, data: encode(ancestor)))]) // Branch 1: change inner.x var b1 = ancestor b1.inner!.x = 10 let v1 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(b1)))]) // Branch 2: change inner.y var b2 = ancestor b2.inner!.y = 20 let v2 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(b2)))]) let merged = try store.mergeRelated(version: v1.id, with: v2.id, resolvingWith: arbiter) let result = try store.value(id: valueId, at: merged.id)! let outer = try JSONDecoder().decode(OuterOptionalModel.self, from: result.data) #expect(outer.inner?.x == 10) #expect(outer.inner?.y == 20) } @Test func optionalMergeableNilToSome() throws { let arbiter = MergeableArbiter() arbiter.register(OuterOptionalModel.self) let valueId = modelValueID(typeIdentifier: "OuterOptionalModel", instanceIdentifier: "om2") let ancestor = OuterOptionalModel(inner: nil, label: "start") let v0 = makeVersion(basedOn: nil, changes: [.insert(Value(id: valueId, data: encode(ancestor)))]) // Branch 1: set inner var b1 = ancestor b1.inner = InnerModel(x: 5, y: 6) let v1 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(b1)))]) // Branch 2: no change to inner let v2 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(ancestor)))]) let merged = try store.mergeRelated(version: v1.id, with: v2.id, resolvingWith: arbiter) let result = try store.value(id: valueId, at: merged.id)! let outer = try JSONDecoder().decode(OuterOptionalModel.self, from: result.data) // Dominant (v1) inserted inner, ancestor was nil → dominant wins #expect(outer.inner?.x == 5) #expect(outer.inner?.y == 6) } @Test func optionalMergeableSomeToNil() throws { let arbiter = MergeableArbiter() arbiter.register(OuterOptionalModel.self) let valueId = modelValueID(typeIdentifier: "OuterOptionalModel", instanceIdentifier: "om3") let ancestor = OuterOptionalModel(inner: InnerModel(x: 1, y: 2), label: "start") let v0 = makeVersion(basedOn: nil, changes: [.insert(Value(id: valueId, data: encode(ancestor)))]) // Branch 1: no change let v1 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(ancestor)))]) // Branch 2: remove inner var b2 = ancestor b2.inner = nil let v2 = makeVersion(basedOn: v0.id, changes: [.update(Value(id: valueId, data: encode(b2)))]) let merged = try store.mergeRelated(version: v1.id, with: v2.id, resolvingWith: arbiter) let result = try store.value(id: valueId, at: merged.id)! let outer = try JSONDecoder().decode(OuterOptionalModel.self, from: result.data) // Dominant unchanged from ancestor, subordinate removed → accept removal #expect(outer.inner == nil) } } ================================================ FILE: Tests/LLVSTests/ArrayDiffTests.swift ================================================ // // ArrayDiffTests.swift // LLVSTests // // Created by Drew McCormack on 02/04/2019. // import Testing @testable import LLVS @Suite struct ArrayDiffTests { var diff: LongestCommonSubsequence! @Test mutating func simpleSequence() { diff = LongestCommonSubsequence(originalValues: [1,3], finalValues: [1,2]) #expect(diff.length == 1) #expect(diff.originalIndexesOfCommonElements == [0]) #expect(diff.finalIndexesOfCommonElements == [0]) #expect(diff.incrementalChanges == [.delete(originalIndex: 1, value: 3), .insert(finalIndex: 1, value: 2)]) } @Test mutating func differingFirstElement() { diff = LongestCommonSubsequence(originalValues: [1,3], finalValues: [2,3]) #expect(diff.length == 1) #expect(diff.originalIndexesOfCommonElements == [1]) #expect(diff.finalIndexesOfCommonElements == [1]) #expect(diff.incrementalChanges == [.delete(originalIndex: 0, value: 1), .insert(finalIndex: 0, value: 2)]) } @Test mutating func removingFromSequence() { diff = LongestCommonSubsequence(originalValues: [1,2,3,4,5,6,7], finalValues: [1,2,4,5,7]) #expect(diff.length == 5) #expect(diff.originalIndexesOfCommonElements == [0,1,3,4,6]) #expect(diff.finalIndexesOfCommonElements == [0,1,2,3,4]) #expect(diff.incrementalChanges == [.delete(originalIndex: 5, value: 6), .delete(originalIndex: 2, value: 3)]) } @Test mutating func addingAndRemovingSequence() { diff = LongestCommonSubsequence(originalValues: [1,2,3,4,5,6,7], finalValues: [2,33,4,36,55,6,7]) #expect(diff.length == 4) #expect(diff.originalIndexesOfCommonElements == [1,3,5,6]) #expect(diff.finalIndexesOfCommonElements == [0,2,5,6]) #expect(diff.incrementalChanges == [.delete(originalIndex: 4, value: 5), .delete(originalIndex: 2, value: 3), .delete(originalIndex: 0, value: 1), .insert(finalIndex: 1, value: 33), .insert(finalIndex: 3, value: 36), .insert(finalIndex: 4, value: 55)]) } @Test func arrayFuncs() { let original = [1,2,3,4,5,6,7] let new = [2,33,4,36,55,6,7] let diff = original.diff(leadingTo: new) #expect(new == original.applying(diff)) } @Test func empty() { let original: [Int] = [] let new: [Int] = [] let diff = original.diff(leadingTo: new) #expect([] == original.applying(diff)) } @Test func originalEmpty() { let original: [Int] = [] let new: [Int] = [1,2,3] let diff = original.diff(leadingTo: new) #expect(new == original.applying(diff)) } @Test func finalEmpty() { let original: [Int] = [1,2,3] let new: [Int] = [] let diff = original.diff(leadingTo: new) #expect(new == original.applying(diff)) } @Test func merge() { let original: [Int] = [1,2] let new1: [Int] = [1,4] let new2: [Int] = [1,2,3] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([1,4,3] == original.applying(mergeDiff)) } @Test func mergeLongSequence() { let original: [Int] = [1,2,3,4,5,6,7,8,9,10] let new1: [Int] = [1,2,3,4,5,5,7,8,9,10] let new2: [Int] = [1,2,3,4,5,6,7,8,10,10] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([1,2,3,4,5,5,7,8,10,10] == original.applying(mergeDiff)) } @Test func twoDeleteMerge() { let original: [Int] = [1,2,3] let new1: [Int] = [2,3] let new2: [Int] = [2,3,4] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([2,3,4] == original.applying(mergeDiff)) } @Test func fourDeleteMerge() { let original: [Int] = [1,2,3] let new1: [Int] = [3] let new2: [Int] = [1] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([] as [Int] == original.applying(mergeDiff)) } @Test func mergeBranchEmpty() { let original: [Int] = [1,2,3] let new1: [Int] = [] let new2: [Int] = [2,3,4] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([4] == original.applying(mergeDiff)) } @Test func mergeOriginalEmpty() { let original: [Int] = [] let new1: [Int] = [1,2,3] let new2: [Int] = [2,3,4] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([1,2,3,2,3,4] == original.applying(mergeDiff)) } @Test func mergeAllEmpty() { let original: [Int] = [] let new1: [Int] = [] let new2: [Int] = [] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([] as [Int] == original.applying(mergeDiff)) } @Test func complexMerge() { let original: [Int] = [1,2,3,4,5] let new1: [Int] = [2,3,6,7] let new2: [Int] = [0,2,3,4,8,9] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([0,2,3,6,7,8,9] == original.applying(mergeDiff)) } @Test func deletesOverlappingInsertsMerge() { let original: [Int] = [1,2,3,4,5] let new1: [Int] = [1,4,5] let new2: [Int] = [1,6,7,2,3,4,5] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([1,6,7,4,5] == original.applying(mergeDiff)) } @Test func insertAtEndMerge() { let original: [Int] = [1,2,3] let new1: [Int] = [1,2,3,4] let new2: [Int] = [1,2,3] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([1,2,3,4] == original.applying(mergeDiff)) } @Test func deleteAndInsertAtEndMerge() { let original: [Int] = [1,2,3] let new1: [Int] = [1,2,3,4] let new2: [Int] = [1,2] let diff1 = original.diff(leadingTo: new1) let diff2 = original.diff(leadingTo: new2) let mergeDiff = ArrayDiff(merging: diff1, with: diff2) #expect([1,2,4] == original.applying(mergeDiff)) } } ================================================ FILE: Tests/LLVSTests/CloudFileSystemExchangeTests.swift ================================================ // // CloudFileSystemExchangeTests.swift // LLVSTests // // Created by Drew McCormack on 03/03/2026. // import Testing import Foundation @testable import LLVS // MARK: - Mock Cloud File System /// An in-memory implementation of `CloudFileSystem` for testing. final class MockCloudFileSystem: CloudFileSystem, @unchecked Sendable { private var files: [String: Data] = [:] func fileExists(at path: String) async throws -> Bool { files[path] != nil } func contentsOfDirectory(at path: String) async throws -> [String] { let prefix = path.hasSuffix("/") ? path : path + "/" var names: Set = [] for key in files.keys { if key.hasPrefix(prefix) { let remainder = String(key.dropFirst(prefix.count)) // Only direct children (no further slashes) if !remainder.contains("/") { names.insert(remainder) } } } return Array(names).sorted() } func upload(data: Data, to path: String) async throws { files[path] = data } func download(from path: String) async throws -> Data { guard let data = files[path] else { throw CloudFileSystemError.fileNotFound } return data } func remove(at path: String) async throws { files.removeValue(forKey: path) } func removeDirectory(at path: String) async throws { let prefix = path.hasSuffix("/") ? path : path + "/" files = files.filter { !$0.key.hasPrefix(prefix) && $0.key != path } } /// Expose stored file paths for assertions. var storedPaths: [String] { Array(files.keys).sorted() } } // MARK: - Tests @Suite class CloudFileSystemExchangeTests { let store1: Store let store2: Store let rootURL1: URL let rootURL2: URL let mockFS: MockCloudFileSystem let exchange1: CloudFileSystemExchange let exchange2: CloudFileSystemExchange init() throws { rootURL1 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) rootURL2 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store1 = try Store(rootDirectoryURL: rootURL1) store2 = try Store(rootDirectoryURL: rootURL2) mockFS = MockCloudFileSystem() exchange1 = CloudFileSystemExchange(cloudFileSystem: mockFS, store: store1) exchange2 = CloudFileSystemExchange(cloudFileSystem: mockFS, store: store2) } deinit { try? FileManager.default.removeItem(at: rootURL1) try? FileManager.default.removeItem(at: rootURL2) } private func value(_ identifier: String, stringData: String) -> Value { Value(id: .init(identifier), data: stringData.data(using: .utf8)!) } // MARK: - Exchange Tests @Test func sendFiles() async throws { let val = value("CDEFGH", stringData: "Origin") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) // Nothing in the cloud yet let pathsBefore = mockFS.storedPaths #expect(pathsBefore.isEmpty) let versionIds = try await exchange1.send() #expect(versionIds.contains(ver.id)) // Verify files were uploaded let pathsAfter = mockFS.storedPaths #expect(pathsAfter.contains { $0.contains("versions/\(ver.id.rawValue)") }) #expect(pathsAfter.contains { $0.contains("changes/\(ver.id.rawValue)") }) } @Test func receiveFiles() async throws { let val = value("CDEFGH", stringData: "Origin") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) let _ = try await exchange1.send() let versionIds = try await exchange2.retrieve() #expect(versionIds.contains(ver.id)) #expect(try ver == store2.version(identifiedBy: ver.id)) #expect(try store2.value(id: val.id, at: ver.id) != nil) } @Test func concurrentChanges() async throws { let origin = try store1.makeVersion(basedOnPredecessor: nil, storing: []) let _ = try await exchange1.send() let _ = try await exchange2.retrieve() func add(numberOfVersions: Int, store: Store) -> ([Version], [Value]) { var versions: [Version] = [] var values: [Value] = [] for _ in 0.. Value { return Value(id: .init(identifier), data: stringData.data(using: .utf8)!) } private var changeFiles: [URL] { return try! fm.contentsOfDirectory(at: exchangeURL.appendingPathComponent("changes"), includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) } private var versionFiles: [URL] { return try! fm.contentsOfDirectory(at: exchangeURL.appendingPathComponent("versions"), includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) } @Test func sendFiles() async throws { let val = value("CDEFGH", stringData: "Origin") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) #expect(changeFiles.count == 0) #expect(versionFiles.count == 0) let versionIds = try await exchange1.send() #expect(versionIds.contains(ver.id)) #expect(self.changeFiles.count == 1) #expect(self.versionFiles.count == 1) } @Test func receiveFiles() async throws { let val = value("CDEFGH", stringData: "Origin") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) let _ = try await exchange1.send() let versionIds = try await exchange2.retrieve() #expect(versionIds.contains(ver.id)) #expect(try ver == self.store2.version(identifiedBy: ver.id)) #expect(try self.store2.value(id: val.id, at: ver.id) != nil) } @Test func concurrentChanges() async throws { let origin = try store1.makeVersion(basedOnPredecessor: nil, storing: []) let _ = try await exchange1.send() let _ = try await exchange2.retrieve() func add(numberOfVersions: Int, store: Store) -> ([Version], [Value]) { var versions: [Version] = [] var values: [Value] = [] for _ in 0.. Value { return Value(id: .init(identifier), data: stringData.data(using: .utf8)!) } // MARK: - Tests @Test func sendPopulatesExchange() async throws { let val = value("CDEFGH", stringData: "Origin") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) let versionIds = try await exchange1.send() #expect(versionIds.contains(ver.id)) } @Test func retrieveFromSharedExchange() async throws { let shared = MemoryExchange(store: store1) let exchange2WithShared = MemoryExchange(store: store2) let val = value("CDEFGH", stringData: "Origin") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) // Send store1's versions into the shared exchange _ = try await shared.send() // Get version data from shared and populate exchange2WithShared let ids = try await shared.retrieveAllVersionIdentifiers() let versions = try await shared.retrieveVersions(identifiedBy: ids) let changesByVersion = try await shared.retrieveValueChanges(forVersionsIdentifiedBy: ids) let versionChanges: [VersionChanges] = versions.map { v in (v, changesByVersion[v.id] ?? []) } try await exchange2WithShared.send(versionChanges: versionChanges) let retrievedIds = try await exchange2WithShared.retrieve() #expect(retrievedIds.contains(ver.id)) let retrievedVersion = try store2.version(identifiedBy: ver.id) #expect(ver == retrievedVersion) #expect(try store2.value(id: val.id, at: ver.id) != nil) } @Test func sendAndRetrieveMultipleVersions() async throws { let val1 = value("AAA", stringData: "First") let ver1 = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val1)]) let val2 = value("BBB", stringData: "Second") let ver2 = try store1.makeVersion(basedOnPredecessor: ver1.id, storing: [.insert(val2)]) let versionIds = try await exchange1.send() #expect(versionIds.count == 2) #expect(versionIds.contains(ver1.id)) #expect(versionIds.contains(ver2.id)) // Verify data is in exchange let ids = try await exchange1.retrieveAllVersionIdentifiers() #expect(ids.count == 2) } @Test func retrieveVersionsById() async throws { let val = value("CDEFGH", stringData: "Origin") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) _ = try await exchange1.send() let versions = try await exchange1.retrieveVersions(identifiedBy: [ver.id]) #expect(versions.count == 1) #expect(versions.first?.id == ver.id) } @Test func retrieveValueChanges() async throws { let val = value("CDEFGH", stringData: "Origin") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) _ = try await exchange1.send() let changesByVersion = try await exchange1.retrieveValueChanges(forVersionsIdentifiedBy: [ver.id]) #expect(changesByVersion.count == 1) #expect(changesByVersion[ver.id]?.count == 1) } @Test func retrieveMissingVersionsReturnsEmpty() async throws { let fakeId = Version.ID(UUID().uuidString) let versions = try await exchange1.retrieveVersions(identifiedBy: [fakeId]) #expect(versions.count == 0) } @Test func restorationStateIsNil() { #expect(exchange1.restorationState == nil) exchange1.restorationState = Data([1, 2, 3]) #expect(exchange1.restorationState == nil) } @Test func newVersionsAvailableStream() async throws { _ = try store1.makeVersion(basedOnPredecessor: nil, storing: []) // Start a task to listen for the notification let notified = Task { for await _ in exchange1.newVersionsAvailable { return true } return false } // Give the listener a moment to start try await Task.sleep(nanoseconds: 50_000_000) _ = try await exchange1.send() // Wait briefly for the notification let result = await Task { try? await Task.sleep(nanoseconds: 200_000_000) notified.cancel() return await notified.value }.value #expect(result) } @Test func sendEmptyIsNoOp() async throws { let ids = try await exchange1.send() #expect(ids.count == 0) } @Test func sendIdempotent() async throws { let val = value("CDEFGH", stringData: "Origin") _ = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) let ids1 = try await exchange1.send() #expect(ids1.count == 1) // Second send should have nothing new to send let ids2 = try await exchange1.send() #expect(ids2.count == 0) } } ================================================ FILE: Tests/LLVSTests/MergeTests.swift ================================================ // // MergeTests.swift // LLVSTests // // Created by Drew McCormack on 12/01/2019. // import Testing import Foundation @testable import LLVS @Suite class MergeTests { let fm = FileManager.default let store: Store let rootURL: URL let valuesURL: URL let originalVersion: Version var branch1: Version var branch2: Version let originalValue: Value let newValue1: Value let newValue2: Value var valueForMerge: Value? init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) valuesURL = rootURL.appendingPathComponent("values") store = try Store(rootDirectoryURL: rootURL) originalValue = Value(id: .init("ABCDEF"), data: "Bob".data(using: .utf8)!) let changes: [Value.Change] = [.insert(originalValue)] originalVersion = try store.makeVersion(basedOn: nil, storing: changes) newValue1 = Value(id: .init("ABCDEF"), data: "Tom".data(using: .utf8)!) let changes1: [Value.Change] = [.insert(newValue1)] let predecessors: Version.Predecessors = .init(idOfFirst: originalVersion.id, idOfSecond: nil) branch1 = try store.makeVersion(basedOn: predecessors, storing: changes1) newValue2 = Value(id: .init("ABCDEF"), data: "Jerry".data(using: .utf8)!) let changes2: [Value.Change] = [.insert(newValue2)] branch2 = try store.makeVersion(basedOn: predecessors, storing: changes2) } deinit { try? FileManager.default.removeItem(at: rootURL) } @Test func unresolvedMergeFails() { class Arbiter: MergeArbiter { func changes(toResolve merge: Merge, in store: Store) -> [Value.Change] { #expect(merge.forksByValueIdentifier.count == 1) return [] } } #expect(throws: (any Error).self) { try store.mergeRelated(version: branch1.id, with: branch2.id, resolvingWith: Arbiter()) } } @Test func resolvedMergeSucceeds() throws { class Arbiter: MergeArbiter { func changes(toResolve merge: Merge, in store: Store) -> [Value.Change] { let value = Value(id: .init("ABCDEF"), data: "Jack".data(using: .utf8)!) return [.insert(value)] } } _ = try store.mergeRelated(version: branch1.id, with: branch2.id, resolvingWith: Arbiter()) } @Test func incompletelyResolvedMergeFails() { class Arbiter: MergeArbiter { func changes(toResolve merge: Merge, in store: Store) -> [Value.Change] { let value = Value(id: .init("CDEDEF"), data: "Jack".data(using: .utf8)!) return [.insert(value)] } } #expect(throws: (any Error).self) { try store.mergeRelated(version: branch1.id, with: branch2.id, resolvingWith: Arbiter()) } } @Test func preserve() throws { class Arbiter: MergeArbiter { func changes(toResolve merge: Merge, in store: Store) -> [Value.Change] { let fork = merge.forksByValueIdentifier[.init("ABCDEF")] #expect(fork == .twiceUpdated) let firstValue = try! store.value(id: .init("ABCDEF"), at: merge.versions.first.id)! return [.preserve(firstValue.reference!)] } } let mergeVersion = try store.mergeRelated(version: branch1.id, with: branch2.id, resolvingWith: Arbiter()) let mergeValue = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(mergeValue.data == "Tom".data(using: .utf8)!) } @Test func asymmetricBranchPreserve() throws { class Arbiter: MergeArbiter { func changes(toResolve merge: Merge, in store: Store) -> [Value.Change] { let fork = merge.forksByValueIdentifier[.init("ABCDEF")] #expect(fork == .twiceUpdated) let firstValue = try! store.value(id: .init("ABCDEF"), at: merge.versions.second.id)! return [.preserve(firstValue.reference!)] } } let predecessors: Version.Predecessors = .init(idOfFirst: branch2.id, idOfSecond: nil) let newValue = Value(id: .init("ABCDEF"), data: "Pete".data(using: .utf8)!) branch2 = try store.makeVersion(basedOn: predecessors, storing: [.update(newValue)]) let mergeVersion = try store.mergeRelated(version: branch1.id, with: branch2.id, resolvingWith: Arbiter()) let mergeValue = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(mergeValue.data == "Pete".data(using: .utf8)!) } @Test func remove() throws { class Arbiter: MergeArbiter { func changes(toResolve merge: Merge, in store: Store) -> [Value.Change] { let fork = merge.forksByValueIdentifier[.init("ABCDEF")] #expect(fork == .removedAndUpdated(removedOn: .second)) return [.preserveRemoval(.init("ABCDEF"))] } } let predecessors: Version.Predecessors = .init(idOfFirst: branch2.id, idOfSecond: nil) branch2 = try store.makeVersion(basedOn: predecessors, storing: [.remove(.init("ABCDEF"))]) let mergeVersion = try store.mergeRelated(version: branch1.id, with: branch2.id, resolvingWith: Arbiter()) let mergeValue = try store.value(id: .init("ABCDEF"), at: mergeVersion.id) #expect(mergeValue == nil) } @Test func twiceRemoved() throws { class Arbiter: MergeArbiter { func changes(toResolve merge: Merge, in store: Store) -> [Value.Change] { let fork = merge.forksByValueIdentifier[.init("ABCDEF")] #expect(fork == .twiceRemoved) return [.preserveRemoval(.init("ABCDEF"))] } } let predecessors1: Version.Predecessors = .init(idOfFirst: branch1.id, idOfSecond: nil) branch1 = try store.makeVersion(basedOn: predecessors1, storing: [.remove(.init("ABCDEF"))]) let predecessors2: Version.Predecessors = .init(idOfFirst: branch2.id, idOfSecond: nil) branch2 = try store.makeVersion(basedOn: predecessors2, storing: [.remove(.init("ABCDEF"))]) let mergeVersion = try store.mergeRelated(version: branch1.id, with: branch2.id, resolvingWith: Arbiter()) let mergeValue = try store.value(id: .init("ABCDEF"), at: mergeVersion.id) #expect(mergeValue == nil) } @Test func twiceUpdated() throws { class Arbiter: MergeArbiter { func changes(toResolve merge: Merge, in store: Store) -> [Value.Change] { let fork = merge.forksByValueIdentifier[.init("ABCDEF")] #expect(fork == .twiceUpdated) let secondValue = try! store.value(id: .init("ABCDEF"), at: merge.versions.second.id)! return [.preserve(secondValue.reference!)] } } let predecessors1: Version.Predecessors = .init(idOfFirst: branch1.id, idOfSecond: nil) let newValue1 = Value(id: .init("ABCDEF"), data: "Pete".data(using: .utf8)!) branch1 = try store.makeVersion(basedOn: predecessors1, storing: [.update(newValue1)]) let predecessors2: Version.Predecessors = .init(idOfFirst: branch2.id, idOfSecond: nil) let newValue2 = Value(id: .init("ABCDEF"), data: "Joyce".data(using: .utf8)!) branch2 = try store.makeVersion(basedOn: predecessors2, storing: [.update(newValue2)]) let mergeVersion = try store.mergeRelated(version: branch1.id, with: branch2.id, resolvingWith: Arbiter()) let mergeValue = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(mergeValue.data == "Joyce".data(using: .utf8)!) } @Test func twoWayMerge() throws { let secondValue = Value(id: .init("CDEFGH"), data: "Dave".data(using: .utf8)!) let newValue = Value(id: .init("ABCDEF"), data: "Joyce".data(using: .utf8)!) let changes: [Value.Change] = [.insert(secondValue), .update(newValue)] let secondVersion = try store.makeVersion(basedOn: nil, storing: changes) let arbiter = MostRecentChangeFavoringArbiter() let mergeVersion = try store.mergeUnrelated(version: originalVersion.id, with: secondVersion.id, resolvingWith: arbiter) let mergeValue = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(mergeValue.data == "Joyce".data(using: .utf8)!) let insertedValue = try store.value(id: .init("CDEFGH"), at: mergeVersion.id)! #expect(insertedValue.data == "Dave".data(using: .utf8)!) } @Test func twoWayMergeDeletion() throws { let changes: [Value.Change] = [] let arbiter = MostRecentChangeFavoringArbiter() let secondVersion = try store.makeVersion(basedOn: nil, storing: changes) let mergeVersion = try store.mergeUnrelated(version: originalVersion.id, with: secondVersion.id, resolvingWith: arbiter) let mergeValue = try store.value(id: .init("ABCDEF"), at: mergeVersion.id) #expect(mergeValue != nil) } } ================================================ FILE: Tests/LLVSTests/MostRecentBranchArbiterTests.swift ================================================ // // MergeArbiterTests.swift // LLVSTests // // Created by Drew McCormack on 09/03/2019. // import Testing import Foundation @testable import LLVS @Suite class MostRecentBranchMergeArbiterTests { let fm = FileManager.default let store: Store let rootURL: URL let origin: Version let recentBranchArbiter: MostRecentBranchFavoringArbiter init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store = try Store(rootDirectoryURL: rootURL) let originVal = Value(id: .init("CDEFGH"), data: "Origin".data(using: .utf8)!) origin = try store.makeVersion(basedOnPredecessor: nil, storing: [.insert(originVal)]) recentBranchArbiter = MostRecentBranchFavoringArbiter() } deinit { try? FileManager.default.removeItem(at: rootURL) } private func value(_ identifier: String, stringData: String) -> Value { return Value(id: .init(identifier), data: stringData.data(using: .utf8)!) } @Test func remove() throws { let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: []) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentBranchArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id) #expect(f == nil) } @Test func twiceRemove() throws { let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentBranchArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id) #expect(f == nil) } @Test func insert() throws { do { let val1 = value("ABCDEF", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: []) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentBranchArbiter) let f = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(f.data == "Bob".data(using: .utf8)) #expect(f.storedVersionId == ver1.id) } do { let val1 = value("ABCDEF", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: []) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val1)]) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentBranchArbiter) let f = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(f.data == "Bob".data(using: .utf8)) #expect(f.storedVersionId == ver2.id) } } @Test func twiceInserted() throws { let val1 = value("ABCDEF", stringData: "Bob") let val2 = value("ABCDEF", stringData: "Tom") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val2)]) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentBranchArbiter) let f = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(f.data == "Tom".data(using: .utf8)) #expect(f.storedVersionId == ver2.id) } @Test func updated() throws { let val1 = value("CDEFGH", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: []) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentBranchArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id)! #expect(f.data == "Bob".data(using: .utf8)) #expect(f.storedVersionId == ver1.id) } @Test func twiceUpdated() throws { let val1 = value("CDEFGH", stringData: "Bob") let val2 = value("CDEFGH", stringData: "Tom") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val2)]) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentBranchArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id)! #expect(f.data == "Tom".data(using: .utf8)) #expect(f.storedVersionId == ver2.id) } @Test func removedAndUpdated() throws { do { let val1 = value("CDEFGH", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentBranchArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id) #expect(f == nil) } do { let val1 = value("CDEFGH", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val1)]) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentBranchArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id)! #expect(f.data == "Bob".data(using: .utf8)) #expect(f.storedVersionId == ver2.id) } } } ================================================ FILE: Tests/LLVSTests/MostRecentChangeArbiterTests.swift ================================================ // // MostRecentChangeFavoringArbiterTests.swift // LLVSTests // // Created by Drew McCormack on 10/03/2019. // import Testing import Foundation @testable import LLVS @Suite class MostRecentChangeMergeArbiterTests { let fm = FileManager.default let store: Store let rootURL: URL let origin: Version let recentChangeArbiter: MostRecentChangeFavoringArbiter init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store = try Store(rootDirectoryURL: rootURL) let originVal = Value(id: .init("CDEFGH"), data: "Origin".data(using: .utf8)!) origin = try store.makeVersion(basedOnPredecessor: nil, storing: [.insert(originVal)]) recentChangeArbiter = MostRecentChangeFavoringArbiter() } deinit { try? FileManager.default.removeItem(at: rootURL) } private func value(_ identifier: String, stringData: String) -> Value { return Value(id: .init(identifier), data: stringData.data(using: .utf8)!) } @Test func remove() throws { let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: []) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id) #expect(f == nil) } @Test func twiceRemove() throws { let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let ver3 = try store.makeVersion(basedOnPredecessor: ver2.id, storing: []) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver3.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id) #expect(f == nil) } @Test func insert() throws { do { let val1 = value("ABCDEF", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: []) let ver3 = try store.makeVersion(basedOnPredecessor: ver1.id, storing: []) let mergeVersion = try store.mergeRelated(version: ver3.id, with: ver2.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(f.data == "Bob".data(using: .utf8)) #expect(f.storedVersionId == ver1.id) } do { let val1 = value("ABCDEF", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: []) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val1)]) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(f.data == "Bob".data(using: .utf8)) #expect(f.storedVersionId == ver2.id) } } @Test func twiceInserted() throws { let val1 = value("ABCDEF", stringData: "Bob") let val2 = value("ABCDEF", stringData: "Tom") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val2)]) let ver3 = try store.makeVersion(basedOnPredecessor: ver1.id, storing: []) let mergeVersion = try store.mergeRelated(version: ver3.id, with: ver2.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(f.data == "Tom".data(using: .utf8)) #expect(f.storedVersionId == ver2.id) } @Test func twiceInsertedAndUpdated() throws { let val1 = value("ABCDEF", stringData: "Bob") let val2 = value("ABCDEF", stringData: "Tom") let val3 = value("ABCDEF", stringData: "Dave") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val2)]) let ver3 = try store.makeVersion(basedOnPredecessor: ver1.id, storing: [.update(val3)]) let mergeVersion = try store.mergeRelated(version: ver3.id, with: ver2.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("ABCDEF"), at: mergeVersion.id)! #expect(f.data == "Dave".data(using: .utf8)) #expect(f.storedVersionId == ver3.id) } @Test func updated() throws { let val1 = value("CDEFGH", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: []) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id)! #expect(f.data == "Bob".data(using: .utf8)) #expect(f.storedVersionId == ver1.id) } @Test func twiceUpdated() throws { let val1 = value("CDEFGH", stringData: "Bob") let val2 = value("CDEFGH", stringData: "Tom") let val3 = value("CDEFGH", stringData: "Dave") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val2)]) let ver3 = try store.makeVersion(basedOnPredecessor: ver1.id, storing: [.update(val3)]) let mergeVersion = try store.mergeRelated(version: ver3.id, with: ver2.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id)! #expect(f.data == "Dave".data(using: .utf8)) #expect(f.storedVersionId == ver3.id) } @Test func removedAndUpdated() throws { do { let val1 = value("CDEFGH", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id)! #expect(f.data == "Bob".data(using: .utf8)) #expect(f.storedVersionId == ver1.id) } do { let val1 = value("CDEFGH", stringData: "Bob") let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.remove(.init("CDEFGH"))]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.update(val1)]) let mergeVersion = try store.mergeRelated(version: ver1.id, with: ver2.id, resolvingWith: recentChangeArbiter) let f = try store.value(id: .init("CDEFGH"), at: mergeVersion.id)! #expect(f.data == "Bob".data(using: .utf8)) #expect(f.storedVersionId == ver2.id) } } } ================================================ FILE: Tests/LLVSTests/MultipeerExchangeTests.swift ================================================ // // MultipeerExchangeTests.swift // LLVSTests // // Created by Drew McCormack on 01/03/2026. // import Testing import Foundation @testable import LLVS /// A mock transport that routes data between two MultipeerExchange instances. class MockPeerTransport: PeerTransport { var peers: [String: MultipeerExchange] = [:] func send(_ data: Data, toPeer peerID: String) throws { guard let exchange = peers[peerID] else { throw MultipeerExchange.Error.transportUnavailable } // Deliver asynchronously to simulate real network Task { exchange.receiveData(data) } } } @Suite class MultipeerExchangeTests { let fm = FileManager.default let store1: Store let store2: Store let rootURL1: URL let rootURL2: URL let exchange1: MultipeerExchange let exchange2: MultipeerExchange let transport: MockPeerTransport init() throws { rootURL1 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) rootURL2 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store1 = try Store(rootDirectoryURL: rootURL1) store2 = try Store(rootDirectoryURL: rootURL2) transport = MockPeerTransport() // exchange1 sends to "peer2", exchange2 sends to "peer1" exchange1 = MultipeerExchange(store: store1, peerID: "peer2", transport: transport) exchange2 = MultipeerExchange(store: store2, peerID: "peer1", transport: transport) // Register exchanges so transport can deliver messages transport.peers["peer1"] = exchange1 transport.peers["peer2"] = exchange2 } deinit { try? fm.removeItem(at: rootURL1) try? fm.removeItem(at: rootURL2) } private func value(_ identifier: String, stringData: String) -> Value { return Value(id: .init(identifier), data: stringData.data(using: .utf8)!) } // MARK: - Tests @Test func retrieveVersionIdentifiers() async throws { let val = value("AABBCC", stringData: "Hello") _ = try store2.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) let ids = try await exchange1.retrieveAllVersionIdentifiers() #expect(ids.count == 1) } @Test func retrieveVersions() async throws { let val = value("AABBCC", stringData: "Hello") let ver = try store2.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) let versions = try await exchange1.retrieveVersions(identifiedBy: [ver.id]) #expect(versions.count == 1) #expect(versions.first?.id == ver.id) } @Test func retrieveValueChanges() async throws { let val = value("AABBCC", stringData: "Hello") let ver = try store2.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) let changesByVersion = try await exchange1.retrieveValueChanges(forVersionsIdentifiedBy: [ver.id]) #expect(changesByVersion.count == 1) #expect(changesByVersion[ver.id]?.count == 1) } @Test func fullSendThenRetrieveCycle() async throws { let val = value("AABBCC", stringData: "Hello") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) // Send from store1 to store2's exchange let sentIds = try await exchange1.send() #expect(sentIds.contains(ver.id)) // Retrieve into store2 from store1's exchange let retrievedIds = try await exchange2.retrieve() #expect(retrievedIds.contains(ver.id)) // Verify store2 now has the version let storedVersion = try store2.version(identifiedBy: ver.id) #expect(storedVersion != nil) // Verify value is accessible let storedVal = try store2.value(id: val.id, at: ver.id) #expect(storedVal != nil) #expect(storedVal?.data == val.data) } @Test func bidirectionalSync() async throws { // Store1 creates a version let val1 = value("AAA", stringData: "From store1") let ver1 = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val1)]) // Store2 creates a version let val2 = value("BBB", stringData: "From store2") let ver2 = try store2.makeVersion(basedOnPredecessor: nil, storing: [.insert(val2)]) // Send from store1 -> store2's exchange _ = try await exchange1.send() // Retrieve into store2 from store1's exchange _ = try await exchange2.retrieve() // Send from store2 -> store1's exchange _ = try await exchange2.send() // Retrieve into store1 from store2's exchange _ = try await exchange1.retrieve() // Both stores should have both versions #expect(try store1.version(identifiedBy: ver2.id) != nil) #expect(try store2.version(identifiedBy: ver1.id) != nil) } @Test func pushToRecipient() async throws { let val = value("PUSHVAL", stringData: "Pushed data") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) // Prepare the version changes to push let changes = try store1.valueChanges(madeInVersionIdentifiedBy: ver.id) let versionChanges: [VersionChanges] = [(ver, changes)] try await exchange1.send(versionChanges: versionChanges) // Poll for async delivery (push is fire-and-forget with two async hops) var storedVersion: Version? = nil for _ in 0..<100 { try await Task.sleep(nanoseconds: 50_000_000) // 50ms storedVersion = try store2.version(identifiedBy: ver.id) if storedVersion != nil { break } } #expect(storedVersion != nil) } @Test func timeoutOnDisconnectedPeer() async throws { // Create an exchange with no transport let disconnectedExchange = MultipeerExchange(store: store1, peerID: "nonexistent", transport: nil) do { _ = try await disconnectedExchange.retrieveAllVersionIdentifiers() Issue.record("Should have thrown transportUnavailable") } catch MultipeerExchange.Error.transportUnavailable { // Expected } } @Test func emptyStoreReturnsNoVersions() async throws { let ids = try await exchange1.retrieveAllVersionIdentifiers() #expect(ids.count == 0) } @Test func newVersionsAvailableOnPush() async throws { let val = value("NOTIFY", stringData: "Notification test") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) // Listen for new versions on exchange2 let notified = Task { for await _ in exchange2.newVersionsAvailable { return true } return false } // Give the listener time to start try await Task.sleep(nanoseconds: 50_000_000) // Push from store1 to store2 let changes = try store1.valueChanges(madeInVersionIdentifiedBy: ver.id) try await exchange1.send(versionChanges: [(ver, changes)]) // Wait for notification let result = await Task { try? await Task.sleep(nanoseconds: 500_000_000) notified.cancel() return await notified.value }.value #expect(result) } } ================================================ FILE: Tests/LLVSTests/PerformanceTests.swift ================================================ // // PerformanceTests.swift // LLVSTests // // Created by Drew McCormack on 14/02/2019. // import Testing import Foundation @testable import LLVS @Suite class PerformanceTests { let fm = FileManager.default let valueId1 = Value.ID("ABCDEF") let valueId2 = Value.ID("ABCDGH") let store: Store let rootURL: URL init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store = try Store(rootDirectoryURL: rootURL) } deinit { try? FileManager.default.removeItem(at: rootURL) } func makeChanges(_ number: Int) -> [Value.Change] { return (0.. [Value.Change] { return (0.. Value { return Value(id: .init(identifier), data: stringData.data(using: .utf8)!) } @Test func reloadHistoryInSecondStore() throws { let val = value("CDEFGH", stringData: "Origin") let ver = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val)]) try store2.reloadHistory() let version = try store2.version(identifiedBy: ver.id) #expect(version != nil) let retrievedData = try store2.value(id: .init("CDEFGH"), storedAt: ver.id)!.data #expect(val.data == retrievedData) } @Test func twoWayTransfer() throws { let val1 = value("CDEFGH", stringData: "One") let ver1 = try store1.makeVersion(basedOnPredecessor: nil, storing: [.insert(val1)]) try store2.reloadHistory() let val2 = value("CDEFGH", stringData: "Two") let ver2 = try store1.makeVersion(basedOnPredecessor: ver1.id, storing: [.update(val2)]) try store1.reloadHistory() let version = try store1.version(identifiedBy: ver2.id) #expect(version != nil) let retrievedData = try store1.value(id: .init("CDEFGH"), storedAt: ver2.id)!.data #expect(val2.data == retrievedData) } } ================================================ FILE: Tests/LLVSTests/SnapshotTests.swift ================================================ // // SnapshotTests.swift // LLVSTests // // Created by Drew McCormack on 09/02/2026. // import Testing import Foundation @testable import LLVS @testable import LLVSSQLite // MARK: - Storage-Level Tests @Suite class SnapshotStorageTests { let fm = FileManager.default let store: Store let rootURL: URL init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store = try Store(rootDirectoryURL: rootURL) } deinit { try? FileManager.default.removeItem(at: rootURL) } private func value(_ id: String, _ string: String) -> Value { Value(id: .init(id), data: string.data(using: .utf8)!) } @discardableResult private func makeLinearChain(count: Int, store: Store? = nil) -> [Version] { let s = store ?? self.store var versions: [Version] = [] var predecessor: Version.ID? = nil for i in 0.. 1, "Small maxChunkSize should produce multiple chunks") // Verify round-trip with chunked snapshot let rootURL2 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) defer { try? fm.removeItem(at: rootURL2) } try fm.createDirectory(at: rootURL2, withIntermediateDirectories: true, attributes: nil) try storage.restoreFromSnapshotChunks(storeRootURL: rootURL2, from: snapshotDir, manifest: manifest) let store2 = try Store(rootDirectoryURL: rootURL2) // Verify integrity let latestVal = try store2.value(id: .init("val49"), at: versions.last!.id) #expect(latestVal != nil) #expect(String(data: latestVal!.data, encoding: .utf8) == "data49") } @Test func snapshotManifestContents() throws { makeLinearChain(count: 50) let storage = FileStorage() let snapshotDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) defer { try? fm.removeItem(at: snapshotDir) } let manifest = try storage.writeSnapshotChunks(storeRootURL: rootURL, to: snapshotDir, maxChunkSize: 5_000_000) #expect(manifest.format == "zip-v1") #expect(manifest.versionCount == 50) #expect(manifest.chunkCount > 0) #expect(!manifest.latestVersionId.rawValue.isEmpty) #expect(manifest.totalSize > 0) } @Test func sqliteStorageSnapshotRoundTrip() throws { // Create store with SQLite storage try? fm.removeItem(at: rootURL) let sqlStore = try Store(rootDirectoryURL: rootURL, storage: SQLiteStorage()) let versions = makeLinearChain(count: 50, store: sqlStore) let storage = SQLiteStorage() let snapshotDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) defer { try? fm.removeItem(at: snapshotDir) } let manifest = try storage.writeSnapshotChunks(storeRootURL: rootURL, to: snapshotDir, maxChunkSize: 5_000_000) #expect(manifest.format == "zip-v1") #expect(manifest.versionCount == 50) // Restore let rootURL2 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) defer { try? fm.removeItem(at: rootURL2) } try fm.createDirectory(at: rootURL2, withIntermediateDirectories: true, attributes: nil) try storage.restoreFromSnapshotChunks(storeRootURL: rootURL2, from: snapshotDir, manifest: manifest) let store2 = try Store(rootDirectoryURL: rootURL2, storage: SQLiteStorage()) var versionCount = 0 store2.queryHistory { history in versionCount = history.allVersionIdentifiers.count } #expect(versionCount == 50) let latestVal = try store2.value(id: .init("val49"), at: versions.last!.id) #expect(latestVal != nil) #expect(String(data: latestVal!.data, encoding: .utf8) == "data49") } } // MARK: - Exchange-Level Tests @Suite class SnapshotExchangeTests { let fm = FileManager.default let store1: Store let rootURL1: URL let exchangeURL: URL let exchange1: FileSystemExchange init() throws { rootURL1 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store1 = try Store(rootDirectoryURL: rootURL1) exchangeURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) exchange1 = FileSystemExchange(rootDirectoryURL: exchangeURL, store: store1, usesFileCoordination: false) } deinit { try? FileManager.default.removeItem(at: rootURL1) try? FileManager.default.removeItem(at: exchangeURL) } private func value(_ id: String, _ string: String) -> Value { Value(id: .init(id), data: string.data(using: .utf8)!) } @discardableResult private func makeLinearChain(count: Int, store: Store? = nil) -> [Version] { let s = store ?? self.store1 var versions: [Version] = [] var predecessor: Version.ID? = nil for i in 0..= 1) } // Regardless of the race outcome, a fresh bootstrap with the current snapshot should work. let rootURL4 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) let cacheURL4 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) defer { try? fm.removeItem(at: rootURL4) try? fm.removeItem(at: cacheURL4) } let coordinator4 = try StoreCoordinator(withStoreDirectoryAt: rootURL4, cacheDirectoryAt: cacheURL4) coordinator4.exchange = FileSystemExchange(rootDirectoryURL: exchangeURL, store: coordinator4.store, usesFileCoordination: false) try await coordinator4.bootstrapFromSnapshot() // The second snapshot (from store2) should now be in effect var retryCount = 0 coordinator4.store.queryHistory { history in retryCount = history.allVersionIdentifiers.count } // 20 versions from store2 snapshot + 1 initial from coordinator4 #expect(retryCount == 21) } @Test func bootstrapSkipsPopulatedStore() async throws { makeLinearChain(count: 50) // Upload snapshot let storage = FileStorage() let snapshotDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) defer { try? fm.removeItem(at: snapshotDir) } let manifest = try storage.writeSnapshotChunks(storeRootURL: rootURL1, to: snapshotDir, maxChunkSize: 5_000_000) try await exchange1.sendSnapshot(manifest: manifest, chunkProvider: { index in let chunkFile = snapshotDir.appendingPathComponent(String(format: "chunk-%03d", index)) return try Data(contentsOf: chunkFile) }) // Create coordinator2 with existing data (> 1 version) let rootURL2 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) let cacheURL2 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) defer { try? fm.removeItem(at: rootURL2) try? fm.removeItem(at: cacheURL2) } let coordinator2 = try StoreCoordinator(withStoreDirectoryAt: rootURL2, cacheDirectoryAt: cacheURL2) // Make some versions so the store is populated try coordinator2.save(inserting: [value("existing1", "data1")]) try coordinator2.save(inserting: [value("existing2", "data2")]) coordinator2.exchange = FileSystemExchange(rootDirectoryURL: exchangeURL, store: coordinator2.store, usesFileCoordination: false) var versionCountBefore = 0 coordinator2.store.queryHistory { history in versionCountBefore = history.allVersionIdentifiers.count } try await coordinator2.bootstrapFromSnapshot() // Version count should be unchanged (bootstrap was skipped) var versionCountAfter = 0 coordinator2.store.queryHistory { history in versionCountAfter = history.allVersionIdentifiers.count } #expect(versionCountBefore == versionCountAfter) } } // MARK: - Policy Tests @Suite class SnapshotPolicyTests { let fm = FileManager.default let rootURL: URL let cacheURL: URL let exchangeURL: URL init() { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) cacheURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) exchangeURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) } deinit { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: cacheURL) try? FileManager.default.removeItem(at: exchangeURL) } private func value(_ id: String, _ string: String) -> Value { Value(id: .init(id), data: string.data(using: .utf8)!) } @Test func snapshotNotUploadedWhenDisabled() async throws { let coordinator = try StoreCoordinator(withStoreDirectoryAt: rootURL, cacheDirectoryAt: cacheURL, snapshotPolicy: .disabled) let exchange = FileSystemExchange(rootDirectoryURL: exchangeURL, store: coordinator.store, usesFileCoordination: false) coordinator.exchange = exchange // Create some versions for i in 0..<10 { try coordinator.save(inserting: [value("val\(i)", "data\(i)")]) } // Exchange try await coordinator.exchange() // Give the fire-and-forget snapshot upload time to complete (if it were to run) try await Task.sleep(for: .seconds(2)) // Verify no snapshot directory let snapshotsDir = exchangeURL.appendingPathComponent("snapshots") #expect(!fm.fileExists(atPath: snapshotsDir.appendingPathComponent("manifest.json").path)) } @Test func snapshotUploadedWhenPolicyMet() async throws { let policy = SnapshotPolicy(enabled: true, minimumInterval: 0, minimumNewVersions: 5) let coordinator = try StoreCoordinator(withStoreDirectoryAt: rootURL, cacheDirectoryAt: cacheURL, snapshotPolicy: policy) let exchange = FileSystemExchange(rootDirectoryURL: exchangeURL, store: coordinator.store, usesFileCoordination: false) coordinator.exchange = exchange // Create > 5 versions for i in 0..<10 { try coordinator.save(inserting: [value("val\(i)", "data\(i)")]) } // Sync to exchange try await coordinator.exchange() // Give the async snapshot upload time to complete try await Task.sleep(for: .seconds(2)) // Verify snapshot was uploaded let snapshotsDir = exchangeURL.appendingPathComponent("snapshots") #expect(fm.fileExists(atPath: snapshotsDir.appendingPathComponent("manifest.json").path), "Snapshot should be uploaded when policy is met") } @Test func snapshotNotReuploadedWhenRecentExists() async throws { let policy = SnapshotPolicy(enabled: true, minimumInterval: 3600, minimumNewVersions: 1) let coordinator = try StoreCoordinator(withStoreDirectoryAt: rootURL, cacheDirectoryAt: cacheURL, snapshotPolicy: policy) let exchange = FileSystemExchange(rootDirectoryURL: exchangeURL, store: coordinator.store, usesFileCoordination: false) coordinator.exchange = exchange // Create versions for i in 0..<10 { try coordinator.save(inserting: [value("val\(i)", "data\(i)")]) } // Manually upload a snapshot with current timestamp let storage = FileStorage() let snapshotDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) defer { try? fm.removeItem(at: snapshotDir) } let manifest = try storage.writeSnapshotChunks(storeRootURL: rootURL, to: snapshotDir, maxChunkSize: 5_000_000) try await exchange.sendSnapshot(manifest: manifest, chunkProvider: { index in let chunkFile = snapshotDir.appendingPathComponent(String(format: "chunk-%03d", index)) return try Data(contentsOf: chunkFile) }) let originalSnapshotId = manifest.snapshotId // Exchange again — snapshot is recent, should not be re-uploaded try await coordinator.exchange() // Wait for potential async upload try await Task.sleep(for: .seconds(2)) // Verify manifest is unchanged (same snapshotId) let downloaded = try await exchange.retrieveSnapshotManifest() #expect(downloaded?.snapshotId == originalSnapshotId, "Snapshot should not have been re-uploaded") } @Test func formatCompatibilityCheck() async throws { // Upload a snapshot with a mismatched format string let coordinator = try StoreCoordinator(withStoreDirectoryAt: rootURL, cacheDirectoryAt: cacheURL) let exchange = FileSystemExchange(rootDirectoryURL: exchangeURL, store: coordinator.store, usesFileCoordination: false) coordinator.exchange = exchange // Write a fake manifest with wrong format let snapshotsDir = exchangeURL.appendingPathComponent("snapshots") try fm.createDirectory(at: snapshotsDir, withIntermediateDirectories: true, attributes: nil) let fakeManifest = SnapshotManifest( format: "unknownFormat-v99", latestVersionId: .init("fake"), versionCount: 100, chunkCount: 1, totalSize: 1000 ) let data = try JSONEncoder().encode(fakeManifest) try data.write(to: snapshotsDir.appendingPathComponent("manifest.json")) // Bootstrap should gracefully skip (no error, no restore) try await coordinator.bootstrapFromSnapshot() // Store should still have just 1 version (the initial empty one created by StoreCoordinator) var versionCount = 0 coordinator.store.queryHistory { history in versionCount = history.allVersionIdentifiers.count } #expect(versionCount == 1) } } ================================================ FILE: Tests/LLVSTests/StoreSetupTests.swift ================================================ import Testing import Foundation @testable import LLVS @Suite class StoreSetupTests { let store: Store let rootURL: URL init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store = try Store(rootDirectoryURL: rootURL) } deinit { try? FileManager.default.removeItem(at: rootURL) } @Test func storeCreatesDirectories() { let fm = FileManager.default let root = rootURL.path as NSString #expect(fm.fileExists(atPath: root as String)) #expect(fm.fileExists(atPath: root.appendingPathComponent("versions"))) #expect(fm.fileExists(atPath: root.appendingPathComponent("values"))) #expect(fm.fileExists(atPath: root.appendingPathComponent("maps"))) } } ================================================ FILE: Tests/LLVSTests/ValueChangesInVersionTests.swift ================================================ // // ValueChangesInVersionTests.swift // LLVSTests // // Created by Drew McCormack on 13/03/2019. // import Testing import Foundation @testable import LLVS @Suite class ValueChangesInVersionTests { let fm = FileManager.default let store: Store let rootURL: URL init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) store = try Store(rootDirectoryURL: rootURL) } deinit { try? FileManager.default.removeItem(at: rootURL) } @Test func valuesConflictlessMerge() throws { let val1 = Value(id: .init("ABCDEF"), data: "Bob".data(using: .utf8)!) var val2 = Value(id: .init("ABCD"), data: "Tom".data(using: .utf8)!) let origin = try store.makeVersion(basedOn: nil, storing: []) let ver1 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val1)]) let ver2 = try store.makeVersion(basedOnPredecessor: origin.id, storing: [.insert(val2)]) val2.storedVersionId = ver2.id let ver3 = try store.makeVersion(basedOn: .init(idOfFirst: ver1.id, idOfSecond: ver2.id), storing: [.preserve(val2.reference!)]) let valueChanges = try store.valueChanges(madeInVersionIdentifiedBy: ver3.id) let allPreserves = valueChanges.allSatisfy { if case .preserve = $0 { return true } else { return false } } #expect(allPreserves) } } ================================================ FILE: Tests/LLVSTests/ValueTests.swift ================================================ import Testing import Foundation @testable import LLVS @Suite class ValueTests { let fm = FileManager.default let store: Store let rootURL: URL let valuesURL: URL let version: Version let originalValue: Value init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) valuesURL = rootURL.appendingPathComponent("values") store = try Store(rootDirectoryURL: rootURL) originalValue = Value(id: .init("ABCDEF"), data: "Bob".data(using: .utf8)!) let changes: [Value.Change] = [.insert(originalValue)] version = try store.makeVersion(basedOn: nil, storing: changes) } deinit { try? FileManager.default.removeItem(at: rootURL) } @Test func savingValueCreatesSubDirectoriesAndFile() { let v = version.id.rawValue let map = v.index(v.startIndex, offsetBy: 1) let versionSubDir = String(v[.. = [version.id, newVersion.id] let fetchedVersions = Set(versionIds) #expect(versions == fetchedVersions) } } ================================================ FILE: Tests/LLVSTests/VersionTests.swift ================================================ // // VersionTests.swift // LLVSTests // // Created by Drew McCormack on 26/01/2019. // import Testing import Foundation @testable import LLVS @Suite class VersionTests { let fm = FileManager.default let store: Store let rootURL: URL let versionsURL: URL let version: Version init() throws { rootURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) versionsURL = rootURL.appendingPathComponent("versions") store = try Store(rootDirectoryURL: rootURL) version = try store.makeVersion(basedOn: nil, storing: []) } deinit { try? FileManager.default.removeItem(at: rootURL) } @Test func creationOfVersionFile() { let v = version.id.rawValue + ".json" let prefix = String(v.prefix(2)) let postfix = String(v.dropFirst(2)) #expect(fm.fileExists(atPath: versionsURL.appendingPathComponent(prefix).appendingPathComponent(postfix).path)) } @Test func loadingOfVersion() throws { let store = try Store(rootDirectoryURL: rootURL) store.queryHistory { history in #expect(history.headIdentifiers == [version.id]) } } } ================================================ FILE: docs/_config.yml ================================================ description: >- Discussion of the Low-Level Versioned Store (LLVS) and other aspects of distributed data storage. baseurl: "/LLVS" url: "https://mentalfaculty.github.io" theme: jekyll-theme-minimal plugins: - jekyll-sitemap - jekyll-feed title: LLVS Blog twitter_username: drewmccormack github_username: mentalfaculty ================================================ FILE: docs/_posts/2019-09-25-data-driven-swiftui.md ================================================ --- layout: post title: "Data Driven SwiftUI" author: Drew McCormack tags: [swiftui] --- SwiftUI has set us all thinking about the future of development on Apple's platforms. It's a disruptive technology which will supersede a UI stack dating back more than 20 years to the pre-Mac OS X era. But while SwiftUI introduces bold new concepts in the UI, what about the rest of our Swift app? Can we disrupt that too? Here I'm going to show you how to build an app that... 1. Uses SwiftUI for views 2. Adopts immutable value types (structs) at every level, and immutable files on disk 3. Syncs seamlessly across devices, with no networking code 4. Has around 450 lines of code in total ## SwiftUI Even with SwiftUI in constant flux, there is already plenty of great content around for learning the new framework. I've spent several weeks working on side projects in an effort to 'kick the tires'; the tutorials and API descriptions others have compiled have been indispensable. And yet, I can't help feeling most of the code is clothed above the waist, and pantsless below. The king is only half dressed. There is an enormous, data-sized elephant in the SwiftUI room. To be more concrete, SwiftUI examples typically rely heavily on the `@State` property wrapper. This is a convenient way to include some mutable state without having to think much about the controllers and data models which make up a real app. This is fully understandable, because the framework is new, and we are still fixated on how to handle animation, layout, and a multitude of other UI concerns. But recently, I've started to focus on the next step: How do you go from the tutorials and demos to real world, scalable apps? How do you build a SwiftUI app from the ground up? ## Structs Atop Classes One of the most perplexing aspects of the current state of affairs is that we have a framework for UI based on value types — `View` structs — while being encouraged to use reference types at the model level. Model types are still typically represented by classes, and Apple's own solution for data storage, Core Data, is firmly established in the realm of reference types. This feels upside down to me. If I were to choose to use value types in either model or view, I would be inclined to pick the model first. In fact, in the app [Agenda](https://agenda.com), we took exactly this approach: the model is made up of structs, and the view utilizes standard AppKit/UIKit types, _ie_, classes. ## Values All the Way Down Of course, one choice need not exclude the other. Maybe the best solution is to use value types in both the view _and_ the model. It's this option I want to explore here by developing a basic contacts app with SwiftUI. But we'll take it a step further, not only using value types, but also adopting immutable data throughout, right down to the on disk storage. If you want to try out this app without going to the trouble of building it, you can [add it to Test Flight](https://testflight.apple.com/join/CInn5xrF). ![The LoCo App]({{site.baseurl}}/images/data-driven-swiftui/LoCo.png) ## LLVS We'll use the [Low-Level Versioned Store (LLVS)](https://github.com/mentalfaculty/LLVS) for app storage, because it is based entirely on immutable files, and syncs automatically via CloudKit. The full source code for the sample app (LoCo) is [in the LLVS project](https://github.com/mentalfaculty/LLVS/tree/master/Samples/LoCo-SwiftUI/LoCo-SwiftUI). The easiest way to think about LLVS is as Git for your app. The concepts are completely analogous: - They both maintain a full history of versions - Data can be retrieved for any version - Data can be stored to create a new version based on any earlier version - History can be branched, and merged LLVS has the added advantage that it abstracts away all sync and networking code, so building a syncing app is as easy as pushing and pulling with Git. ## Data Driven Because LLVS has a full history of versions, and each version is immutable, our data handling becomes dramatically simpler. The state of the whole app can be derived from a single value: the current version. We can use the Combine framework to monitor changes to the current version, and propagate the changes through the data source class, and into the views. All of the SwiftUI views are literally a function of that one single value. Here is the [relevant code](https://github.com/mentalfaculty/LLVS/blob/master/Samples/LoCo-SwiftUI/LoCo-SwiftUI/ContactsDataSource.swift) from the `ContactsDataStore` class: ```swift final class ContactsDataSource: ObservableObject { let storeCoordinator: StoreCoordinator private var contactsSubscriber: AnyCancellable? init(storeCoordinator: StoreCoordinator) { self.storeCoordinator = storeCoordinator contactsSubscriber = storeCoordinator.currentVersionSubject .receive(on: DispatchQueue.main) .map({ self.fetchedContacts(at: $0) }) .assign(to: \.contacts, on: self) } ... @Published var contacts: [Contact] = [] ``` The `StoreCoordinator` class is our interface to LLVS; it manages the store for us, tracking the current version, and merging changes from other devices. The class has a `currentVersionSubject`, which we can subscribe to. After shunting to the main queue, we use a Combine `map` to convert the current version into a list of contacts for that version. The method `fetchContacts` handles this, querying the `StoreCoordinator` for values stored in the current version, and unpacking the data using the `Codable` protocol to create an array of our model type, `Contact`. After the current contacts are fetched they are assigned to the `contacts` property; because this is `@Published`, it triggers an update to the SwiftUI `View` types, reflecting the data for the user. All of this arises whenever the current version of the `StoreCoordinator` changes, whether due to a local edit, or new data from a remote device. ## The Views The view code is quite standard SwiftUI. We create a list of contacts in `ContactsView`. ```swift struct ContactsView : View { @EnvironmentObject var dataSource: ContactsDataSource ... var body: some View { NavigationView { List { ForEach(dataSource.contacts) { contact in ContactCell(contactID: contact.id) .environmentObject(self.dataSource) } ... } ... ``` The `ContactsDataSource` object is passed in here as an `@EnvironmentObject`, and the `contacts` property from the previous section is used to generate the list cells. When the user taps a cell, a detail view is pushed onto the navigation stack, showing the details of the contact in a form. ```swift struct ContactView: View { @EnvironmentObject var dataSource: ContactsDataSource var contactID: Contact.ID ... var body: some View { NavigationView { Form { Section(header: Text("Name")) { TextField("First Name", text: contact.person.firstName) TextField("Last Name", text: contact.person.secondName) } Section(header: Text("Address")) { TextField("Street Address", text: contact.address.streetAddress) TextField("Postcode", text: contact.address.postCode) TextField("City", text: contact.address.city) TextField("Country", text: contact.address.country) } } .navigationBarTitle(Text("Contact")) } } } ``` This is what it looks like to the user. ![Contact Detail View]({{site.baseurl}}/images/data-driven-swiftui/ContactDetails.png) ## Change Without Mutation So far, we have no mechanism to change the contacts data. This is where it gets more interesting, because we are going to update the contacts without actually mutating any of our data. Let's take the case of updating an existing contact. (Inserting and deleting are very similar.) We need a means to observe changes in the text fields of the `ContactView`. In SwiftUI, that usually means a binding. Here is the `contact` binding that we used to populate the form above. ```swift private var contact: Binding { Binding( get: { () -> Contact in self.dataSource.contact(withID: self.contactID) }, set: { newContact in self.dataSource.update(newContact) } ) } ``` Usually a binding would be a wrapper around a simple value, but, in this case, the getter fetches the contact from the `ContactsDataSource`, and the setter calls an `update` method passing the changed contact. ```swift func update(_ contact: Contact) { let change: Value.Change = .update(try! contact.encodeValue()) try! storeCoordinator.save([change]) sync() } ``` As you can see, the `update` method doesn't actually make any changes to the `contacts` array in `ContactsDataSource`, which is what you would probably expect it to do. Instead, it encodes the new value, and saves it straight into the LLVS store to create a new version. Stop to think about that for a minute: we didn't actually mutate any of the data in our `ContactsDataSource`, or SwiftUI views. We simply created a new `Contact` value, and saved it straight to disk. If we don't update the array of contacts in the data source class, how do edits end up on screen? Well, we saved the new value to the LLVS store, which causes the current version to change, and this induces the chain of observation we started with, updating the whole UI. The cycle is complete. ![The Data Cycle]({{site.baseurl}}/images/data-driven-swiftui/DataFlow.png) ## A Merge at Every Coal Face Who cares? Why is this useful? Here is something you realize when implementing sync in a non-trivial app: whenever you have a mutable copy of the data, you have a merge problem. For example, imagine you fetch data from disk, and store it in a controller. What happens when new changes arrive from a different device? You have to merge those changes into the controller's data. And what happens when the user makes changes in the view? You have to merge those changes into the controller's copy of the data. And the same applies at every level of the app. If you are working on a view class, you have to be careful to pull updates in from the controller, and merge them with any changes the user has just made. In short, there is a merge problem at every coal face. Any mutable copy of your data is another merge problem to solve. The reason the solution above works so well is that there is no mutable copy of the data. The only mutation occurs in the data store when changing the current version. All merging occurs in this step, via the mechanisms provided by LLVS. In this particular example, we have opted for a simple "most recent value wins" merge policy, but we could make merging as sophisticated as needed. We do this in one place, rather than throughout the app for every mutable copy of the data. ## Early Adopters It's still very early days for SwiftUI, but now is the time to start exploring new ways to build apps. Question your assumptions. The dam is ready to break. In this post, we've seen how using a distributed store like LLVS can complement SwiftUI. You can build your whole app around immutable value types, and vastly simplify sync and data merging. ================================================ FILE: docs/index.md ================================================ ## Posts