It's a record store for Swift.
Empire is a minimalistic local persistence system with an emphasis on type safety and low overhead. It is backed by a sorted key-value store that is far simpler than a full SQL database. SQL is very powerful, but for many problem domains you just don't need it.
This might be appealing to you if need more than just a flat file, more than UserDefaults
, but not an entire relational database.
- Schema is defined by your types
- Macro-based API that is both typesafe and low-overhead
- Built for concurrency and cancellation
- Support for CloudKit's
CKRecord
- Backed by a sorted-key index data store (LMDB)
The core LMDB support is also available as a standalone library within the package, just in case that's of interest as well.
Warning
This library is still pretty new and doesn't have great deal of real-world testing yet.
import Empire
// define your records with types
@IndexKeyRecord("name")
struct Person {
let name: String
let age: Int
}
// create a local database
let store = try BackgroundableStore(url: Self.storeURL)
// interact with it using transactions
try store.main.withTransaction { context in
try context.insert(Person(name: "Korben", age: 45))
try context.insert(Person(name: "Leeloo", age: 2000))
}
// run queries
let records = try store.main.withTransaction { context in
try Person.select(in: context, limit: 1, name: .lessThan("Zorg"))
}
print(records.first!) // Person(name: "Leeloo", age: 2000)
// move work to the background
try await store.background.withTransaction { ctx in
try Person.delete(in: ctx, name: "Korben")
}
dependencies: [
.package(url: "https://github.com/mattmassicotte/Empire", branch: "main")
]
Empire stores records in a sorted-key index, which resembles an ordered-map data structure. This has profound implications on query capabilities and how data is modeled. Ordered maps offer much less flexibility than the table-based system of an SQL database. Because of this, the queries you need to support can heavily influence how you model your data.
Conceptually, you can think of each record as being split into two components: the "index key" and "fields".
The "index key" component is the only means of retrieving data efficiently. It is not possible to run queries against non-key fields without doing a full scan of the data. This makes index keys a critical part of your design.
Consider the following record definition. It has a composite key, defined by the two arguments to the @IndexKeyRecord
macro.
@IndexKeyRecord("lastName", "firstName")
struct Person {
let lastName: String
let firstName: String
let age: Int
}
These records are stored in order, first by lastName
and then by firstName
. The ordering of key components is very important. Only the last component of a query can be a non-equality comparison. If you want to look for a range of a key component, you must restrict all previous components.
// scan query on the first component
store.select(lastName: .greaterThan("Dallas"))
// constrain first component, scan query on the second
store.select(lastName: "Dallas", firstName: .lessThanOrEqual("Korben"))
// ERROR: an unsupported key arrangement
store.select(lastName: .lessThan("Zorg"), firstName: .lessThanOrEqual("Jean-Baptiste"))
The code generated by the @IndexKeyRecord
macro makes it a compile-time error to write invalid queries. Because it is not part of the index key, it is not possible to run efficient queries that involve the age
property.
You have a few different options for controlling the thread-safety and execution of your database.
// A non-Sendable type
let store = try Store(path: "/path/to/store")
// An actor-isolated and Sendable store
let backgroundStore = try BackgroundStore(path: "/path/to/store")
// A hybrid that is synchronously accessible from MainActor
// and also supports executing transactions in the background
let backgroundableStore = try BackgroundableStore(path: "/path/to/store")
backgroundableStore.main.withTransaction { ... }
await backgroundableStore.background.withTransaction { ... }
You can also make your own arragements by either hanging onto a Store
directly within an actor you control, or by creating and using the primitive LockingDatabase
type directly.
The transaction mechanism supports cancellation. If the executing Task
is cancelled, a transaction will be aborted.
This is only necessary if you are interested in working on the project yourself.
Note: requires Xcode 16
- clone the repo
git submodule update --init --recursive
To run the benchmarks:
cd Benchmarks
swift package --disable-sandbox benchmark --target CoreDataBenchmarks
swift package benchmark --target EmpireBenchmarks
I'm not sure! I haven't used Core Data or SwiftData too much. But I have used the distributed database Cassandra quite a lot and DynamoDB a bit. Then one day I discovered LMDB. Its data model is quite similar to Cassandra and I got interested in playing around with it. This just kinda materialized from those experiments.
Sure!
User data is important. This library has a bunch of tests, but it has no real-world testing. I plan on using this myself, but even I haven't gotten to that yet. It should be considered functional, but experimental.
I would love to hear from you! Issues or pull requests work great. Both a Matrix space and Discord are available for live help, but I have a strong bias towards answering in the form of documentation. You can also find me on the web.
I prefer collaboration, and would love to find ways to work together if you have a similar project.
I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace.
By participating in this project you agree to abide by the Contributor Code of Conduct.