Amethyst Vein - A cross-platform, open source, SwiftData alternative
TLDR: Amethyst Vein is a SwiftData inspired database framework, with UI support for SwiftUI and SwiftCrossUI out of the box, with a UI independent surface available too. It's compatible with Apple, Linux, Android and Windows.
Links: Repo | Docs & Tutorials
The Swift ecosystem is continuously expanding, I love Swift and I love making apps for myself. But I need them on more than just Apple devices, which is not as pleasant as inside the Apple ecosystem yet.
Contributing to SwiftCrossUI accounts for the UI part, but storing data is still a bit annoying. There are ORMs, but to me they feel a bit out of place in a declarative UI framework. And aside from SwiftDatas problems (these absolutely exist) I really like the API of SwiftData and how it integrates with SwiftUI.
With a combination of underestimating the challenge, curiosity and a goal in mind, I started building the solution: Amethyst Vein.
This is an example of how it can look (ignore the content of the example not making too much sense on the clientside, I was lacking ideas):
enum V0_0_1: VersionedSchema {
static let version = ModelVersion(0, 0, 1)
static let models: [any PersistentModel.Type] = [
Post.self,
Attachment.self
]
@Model
final class Post {
var title: String
var content: String
@Relationship(
inverse: \Attachment.post,
deleteRule: .cascade
)
var attachments: [Attachment]
init(title: String, content: String) {
self.title = title
self.content = content
}
}
@Model
final class Attachment {
@Relationship
var post: Post?
var name: String
var fileType: FileType
var sizeMiB: Double
@LazyField
var data: Data?
init(name: String, fileType: FileType, data: Data) {
self.name = name
self.fileType = fileType
self.sizeMiB = Double(data.count) / 1024 / 1024
self.data = data
}
enum FileType: String, RawRepresentablePersistable {
case png
case jpg
case gif
case swift
// ...
}
}
}
typealias Post = V0_0_1.Post
typealias Attachment = V0_0_1.Attachment
enum Migration: SchemaMigrationPlan {
static let schemas: [VersionedSchema.Type] = [
V0_0_1.self
]
static let stages: [MigrationStage] = []
}
UI independent use:
func setupAndUseVein() throws {
// Optional: Setup keyring for Linux support
#if os(Linux)
Keyring.appIdentifier.withLock { $0 = "com.example.app" }
#endif
let container = try ModelContainer(
V0_0_1.self, // Your VersionedSchema
migration: Migration.self, // Your SchemaMigrationPlan
at: "path/to/db.sqlite3", // or nil for in memory
appID: "com.example.app" // The id of your app
)
try container.migrate()
let post = Post(title: "How to use Vein?", content: "It's very easy.")
try container.context.insert(post)
post.content = "What did I tell you?"
try container.context.save()
let posts = try container.context.fetchAll(#Predicate<Post> { post in
post.title.contains("Vein")
}) // gives back [post]
try container.context.delete(post)
}
Or with a @Query:
struct ContentView: View {
@Query(#Predicate<Post> { post in
post.title.contains("Swift")
})
var posts: [Post]
@Environment(\.modelContext) var context
var body: some View {
Button("Add post") {
do {
try context.insert(Post(title: "New Post", content: "..."))
try context.save()
} catch {
// Update some error state.
}
}
List(posts) { post in
Text(post.title)
}
}
}
If you know SwiftData, using Vein should be very easy to you.
The (hopefully) more interesting stuff:
The first thing you might have noticed is @LazyField. Vein eager loads fields by default, you can choose to explicitly declare that using @Field or, for data that's bigger and/or rarely accessed, you can apply @LazyField instead. It then will fetch the data for that specific field on first access.
The next, very obvious difference is that you are required to use a VersionedSchema and provide a SchemaMigrationPlan. It is good practice and since a database is such a critical piece, I wanted to enforce it. You don't have to migrate anything while you only have one version though (duh).
Aside from the very obvious API differences Vein is also very different under the hood. It's based on SQLite.swift or rather skip's swift-sqlcipher. Database level encryption is enabled by default on every platform and can be opted out of. The same core engine, SQLite+SQLCipher and SQLite.swift are used on all platforms, the only difference is how the database key is stored.
To store the database key, on Apple platforms the keychain via KeychainAccess is used, on Linux it's my own KeyringAccess library using SecretService and on Windows it used CredW. Due to the difficulty with android doing basically everything in their fake JVM you currently need to implement the minimal DatabaseKeyProvider protocol yourself and provide the implementation to the model container when working with android. I hope to resolve that in the future.
The next big difference has to do with the discovery of fields. SwiftData heavily relies on runtime magic (as far as I'm aware including the objc runtime, which obviously isn't available anywhere else). Vein instead uses the @Model macro to generate all the information, accessors and protocol conformances needed. No reflection or anything like that is used, I like compiler/type checker guarantees. If you are interested in what is generated, I invite you to take a look at the macro unit test.
IDs
Vein uses ULIDs. ULIDs are pretty cool, they have a leading timestamp part and a trailing random part, making them lexicographically sortable. This means by default models will always be returned in the order they were created on a fetch. And you can always get the timestamp back, making the ID implicitly a created_at field as well. I love ULIDs (they are also a bit more readable than UUIDv7).
Threading
Another difference is how threading works. Vein models are Sendable and synchronized through locks. So sharing model instances across threads is completely safe (in theory) I still wouldn't recommend mutating the same instance concurrently on multiple threads though, as you might end up with a mix of data from different threads. It can be done though and if you take care of locking the model while mutating, it is completely safe.
Just like SwiftData, Vein heavily relies on it's ModelContext, which in Vein is called ManagedObjectContext. Initially I tried to make it work implementing it as an actor, but I noticed pretty quickly that the combination of an async context and synchronous API doesn't really work. So the context is synchronized via locks too. A context has a weak identity map, to be able to keep the single row, single instance promise (inside a context).
Just like in SwiftData, writes are cached until a save() (currently not invoked manually, so please remember to save ;D). The write cache just keeps strong references so they're not freed until saved.
Saves, changes to the identity map and the write cache are synchronized through locks too. The identity map and write cache are always locked for the shortest time possible, but a long save on a background thread will block the main thread when a second save is called on the same context. For background threads I generally recommend using a separate context, to avoid that issue.
Migrations
Now to migrations. Where SwiftData tries to be magic, Vein is always explicit. For simple schema migrations there are single line helper functions, but you will always have to call them yourself, ensuring you and everyone else knows what is happening. Vein also fails and reverts a migration if there is unhandled data left. The rest of the migrations is pretty much the same as in SwiftData. You fetch old instances, create new instances from it and delete the old ones. Since Vein uses ULIDs as identifiers you might want to transfer the ID though.
Relationships
Just like every proper wrapper around a relational database Vein supports relationships. I did not bother to investigate how SwiftData does it under the hood, but it is probably different. Vein relationships are based on ULIDs. The ULIDs are eagerly fetched with the model and resolved through the context on a get. Relationship information is stored as JSONB on both sides, eliminating annoyances of joined tables and foreign keys. It's not as fast as the "intended" method of foreign keys for filtering large amounts of data (hundreds of thousands of rows) by fields on the relationship's side, but Vein is not intended for server use, so I decided the simpler, more stable solution was the better one. Relationships are managed entirely by Vein, to SQLite it's just a JSONB blob.
Since relationships are managed by the context, you can only set and use relationships on an inserted model. Models added to an inserted one are automatically inserted.
Filtering
I was quite spoiled by the #Predicate macro. To my (pleasant) surprise, it's part of Foundation, so you can use it on Vein, yippieh. Currently it just allows one field deep filtering (meaning no filtering by fields of relationships) and is one of the very few things that can fail at runtime if a non-field property or an unsupported feature are used. Sadly, to my knowledge, there is no way to protocol constrain that.
Alternatively, should you need to optimize or do something special, you can also create a ModelPredicate from an SQLiteExpression<Bool> and a runtime filter closure.
VeinCore, VeinSwiftUI and VeinSCUI
Vein provides 3 API surfaces for public consumption:
VeinCore: for CLI apps, or use with UI frameworks Vein doesn't explicitly supportVeinSwiftUI: Vein for SwiftUI. Includes@Queryand more.VeinSCUI: Vein for SwiftCrossUI. Includes@Queryand more. Needs to be enabled with theVeinSCUItrait.
VeinCore, VeinSCUI and VeinSwiftUI are thin wrappers around the shared Vein engine. The only changes are additive. Almost all of the macro logic is shared too, it's only UI update related stuff that changes. You can expect consistency.
Testing
We all know that data integrity is crucial, and one of the most risky things are database migrations. To help with making testing a bit less annoying, Vein ships with VeinTesting, which includes some tools for validating migrations. I highly recommend unit testing your migrations in some way.
To learn how to use VeinTesting please see the Tutorial.
Where is Vein currently?
I'm happy with the API and some future features are already considered (sync required fields fore example are already generated, so a version upgrade won't require a complicated migration later). I decided to make a feature cut to get it out there, so you might miss some things, like SortDescriptor.
I have 150+ unit tests I run on macOS, Linux, Android and Windows. On macOS and Linux tests are ran with thread sanitizer. The full test suite is ran on VeinCore, VeinSwiftUI and VeinSCUI on at least one platform. There a no known bugs at the moment.
Due to the nature of a 1.0 release or software in general there likely will still be bugs, somewhere. If you encounter one, please report it, to help improve Vein for everyone. Vein is designed in a way to fail fast and crash if irrecoverable to protect the integrity of data. A crash in case of an issue is therefore more likely than data corruption.
There is documentation and tutorials at vein.amethystsoft.de
Plans for the future
In the next few weeks I will add things like SortDescriptor, support for pagination and other smaller features you can request via discussions on the github repo.
Long term the goal is, to have end to end encrypted, selfhostable sync.
How to support the project?
As you can probably imagine, developing such a framework takes up a lot of time. There are three main ways to support the project:
- reporting issues (in some cases maybe also helping to fix them)
- exposure. If you like the project, it would be a huge help if you could share it. I believe this is an important piece of software for Swift apps outside of the apple ecosystem.
- Sponsorships on GitHub or purchasing a commercial license (Vein is MPL 2.0 licensed, but if your company requires it or you just want to support the project, that is possible.)
Vein + Skip?
There currently is an issue with Swift 6.3 and FoundationMacros.Predicate on android. Swift 6.4 works in CI, but I haven't tried to use Vein with Skip yet. I plan to do so in the future and officially support it. If you want to try it and VeinCore or VeinSwiftUI already work with Skip Fuse, please let me know.
Thank you for reading ~Mia