A modern wrapper for Realm — actor-isolated, async/await only, and safe under Swift 6 strict concurrency.
let store = RealmStore(
configuration: StorageConfiguration(schemaVersion: 1, objectTypes: [User.self])
)
try await store.open()
let users = try await store.objects(User.self) {
$0.firstName == "Robert" && $0.events.count >= 5 && $0.updatedAt != nil
}Upgrading from 1.x? 2.0 was a rewrite. See MIGRATION.md for a step-by-step guide. The 1.x line remains available via SPM and as source on the
1.xbranch — but it can no longer be installed through CocoaPods on a current toolchain, for reasons outside this package's control (details).
- Actor-isolated. One
actorowns the Realm; there is no shared mutable global and no thread-confinement bookkeeping to get wrong. - Swift 6 clean. Builds in Swift 6 language mode with zero warnings and no
@uncheckedconformances on Realm's own types. - async/await only. No completion handlers.
- Type-safe queries built on Realm's native
Query— no code generation, no build phase, no generated file. - Live observation through
AsyncThrowingStream, plus a main-actor façade for SwiftUI. - Encryption at rest, with the key held in the Keychain and provisioned automatically.
- One dependency — realm-swift, and nothing else.
.package(url: "https://github.com/AndrewKochulab/RealmStorage.git", from: "2.0.0")Then add RealmStorage to your target's dependencies.
pod 'RealmStorage', '~> 2.1'No build phase is required. 1.x needed a Sourcery run to generate query schemas; 2.0 does not.
Models are ordinary Realm objects using @Persisted, marked with StorageObject — or IdentifiableStorage when they have a primary key.
import RealmSwift
import RealmStorage
final class User: Object, IdentifiableStorage {
@Persisted(primaryKey: true) var id: UUID
@Persisted var firstName: String
@Persisted var lastName: String
@Persisted var age: Int
@Persisted var createdAt: Date
@Persisted var updatedAt: Date?
@Persisted var events: List<Event>
}
final class Event: Object, IdentifiableStorage {
@Persisted(primaryKey: true) var id: UUID
@Persisted var name: String
@Persisted var date: Date?
}IdentifiableStorage takes the key's type from your id property, so String, Int, UUID and ObjectId all work. Looking an object up by id on a type without a primary key is a compile error.
For a composite key, build the string with CompoundID:
final class EventMember: Object, IdentifiableStorage {
@Persisted(primaryKey: true) var id: String
@Persisted var user: User?
@Persisted var event: Event?
func updateCompoundID() {
guard let user, let event else { return }
id = CompoundID.make(user.id.uuidString, event.id.uuidString)
}
}let store = RealmStore(
configuration: StorageConfiguration(
schemaVersion: 1,
objectTypes: [User.self, Event.self, EventMember.self]
)
)
try await store.open()StorageConfiguration is a Sendable value. The defaults are chosen so that the safe thing happens without configuration:
| Default | Why | |
|---|---|---|
location |
.applicationSupport |
Not user-visible, and excluded from backup by default |
fileProtection |
.completeUntilFirstUserAuthentication |
Data protection stays on, while background launches still work |
excludedFromBackup |
true |
The encryption key is device-only; a restored backup would have no key for the file |
corruptionPolicy |
.rethrow |
Nothing is ever deleted without you asking |
encryption |
.none |
Opt in explicitly |
let configuration = StorageConfiguration(
schemaVersion: 1,
encryption: .keychain(store: KeychainSecretStore()),
objectTypes: [User.self]
)A 64-byte key is generated on first launch and kept in the Keychain as afterFirstUnlockThisDeviceOnly, so a background launch on a locked device can still open the database. If an unencrypted database already exists, it is copied into the encrypted file and the plaintext one is removed.
With encryption on, the file is named <fileName>_encrypted.realm — the suffix is what makes that one-time migration from a 1.x plaintext database possible. Use StorageLocation.file(_:) when you want to name the file yourself.
You can supply your own key with .key(_:), or your own storage by conforming to SecretStore.
Queries are composable values built on Realm's native type-safe Query.
let query = DatabaseQuery<User>()
.where { $0.firstName == "Robert" && $0.events.count >= 5 }
.sorted(by: \.createdAt, ascending: false)
.limited(to: 20)
let users = try await store.objects(User.self, matching: query)Building on a query returns a new one, so a shared base is safe to hold and extend.
limited(to:) applies everywhere the query is used — reads and writes alike — so
delete(_:matching: query.limited(to: 5)) removes at most five objects.
For a one-off filter, pass the closure directly:
let adults = try await store.objects(User.self) { $0.age >= 18 }let all = try await store.all(User.self)
let user = try await store.object(User.self, id: someUUID)
let some = try await store.objects(User.self, ids: [idA, idB])
let first = try await store.first(User.self, matching: query)
let total = try await store.count(User.self)
let anyMatch = try await store.contains(User.self, matching: query)Reads return frozen values, so they are safe to pass anywhere. StorageResults stays lazy — call .array() when you want everything materialised. Single objects come back as Frozen<Element>, which reads through to the object, so user?.firstName works as written.
To get plain value types out instead, map inside the store:
let names = try await store.objects(User.self, matching: query) { $0.firstName }try await store.save(User(id: UUID(), firstName: "Steve"))
try await store.save([userA, userB], update: .modified)
try await store.update(User.self, id: userID) { user in
user.firstName = "Tony"
}
try await store.delete(User.self, id: userID)
try await store.delete(User.self, matching: DatabaseQuery { $0.isActive == false })For several changes in one transaction:
try await store.write { realm in
realm.add(user, update: .modified)
realm.add(event, update: .modified)
}A throwing block rolls the whole transaction back.
for try await change in await store.changes(of: User.self, matching: query) {
switch change {
case .initial(let users):
render(users)
case .update(let users, let deletions, let insertions, let modifications):
apply(users, deletions, insertions, modifications)
}
}Payloads are frozen. Ending the loop invalidates the notification token. With a
limited(to:) query the reported indices are those visible within the limit, so
results[index] is always safe.
To watch one object instead, pass its primary key. The stream reports which properties changed, and finishes once the object is deleted:
for try await change in await store.changes(of: User.self, id: userID) {
switch change {
case .initial(let user), .change(let user, _):
render(user)
case .deleted:
dismiss()
}
}RealmStore hands back frozen snapshots, which is what makes it safe to share — but SwiftUI wants live, auto-updating results. MainRealmStore provides those, pinned to the main actor:
@MainActor
final class UserListModel: ObservableObject {
private let store = MainRealmStore(
configuration: StorageConfiguration(objectTypes: [User.self])
)
@Published var users: Results<User>?
func load() async throws {
try await store.open()
users = try store.objects(User.self) { $0.isActive == true }
}
}Both types can point at the same StorageConfiguration.
writeCopy produces a compacted copy — useful for backups, support bundles, or shipping a
seed database:
try await store.writeCopy(to: backupURL) // plaintext
try await store.writeCopy(to: backupURL, encryptionKey: key) // encryptedOpen a copy again with StorageLocation.file(_:), which uses the path exactly as given
rather than applying the usual naming convention:
let restored = RealmStore(
configuration: StorageConfiguration(location: .file(backupURL), objectTypes: [User.self])
)Realm files only grow, so a long-lived database with heavy churn can accumulate dead
space. fileSize() tells you how much, and compaction is opt-in:
configuration.shouldCompactOnLaunch = { total, used in
total > 100 * 1024 * 1024 && Double(used) / Double(total) < 0.5
}Express each schema change as a step; RealmStorage runs the ones that apply, in order.
let migrator = SchemaMigrator {
SchemaMigrationStep(toVersion: 2) { migration in
migration.renameProperty(onType: "User", from: "name", to: "firstName")
}
SchemaMigrationStep(toVersion: 3) { migration in
migration.enumerateObjects(ofType: "User") { _, new in
new?["isActive"] = true
}
}
}
var configuration = StorageConfiguration(schemaVersion: 3, objectTypes: [User.self])
configuration.migrate = migrator.migrationBlockRealm's Query covers everything the 1.x PredicateFlow layer did:
| Need | Write |
|---|---|
| Equality | $0.name == "Robert", $0.name != "Robert" |
| Membership | $0.name.in(["Robert", "Tony"]) |
| Comparison | $0.age > 30, $0.age <= 30 |
| Optionals | $0.updatedAt != nil |
| Booleans | $0.isActive == true |
| Strings | .contains, .starts(with:), .ends(with:), .like |
| Case / diacritics | $0.name.contains("rob", options: .caseInsensitive) |
| Negation | !$0.name.contains("x") |
| Compound | &&, || |
| Collections | $0.events.count >= 5, .min, .max, .avg, .sum |
| Relationships | $0.events.name == "Talk" (implicit ANY) |
For anything left over — SUBQUERY(...).@count, NONE, BETWEEN — drop to a predicate:
DatabaseQuery<User>().filter {
NSPredicate(format: "SUBQUERY(events, $e, $e.name == %@).@count > 1", "Talk")
}Realm's own limits. Realm's query engine rejects the
ALLmodifier andMATCHES(regular expressions) in every form, and supports onlyAND,ORandNOTcompound predicates. RealmStorage 1.x exposedall(_:)andmatches(_:)through PredicateFlow, but neither ever worked against a Realm database. An unsupported predicate raises an Objective-C exception rather than throwing, so it cannot be caught — cover raw predicates with a test.
| Swift | 6.0+ |
| Xcode | 26.0+ |
| Platforms | iOS 13+, macOS 10.15+, tvOS 13+, watchOS 6+ |
| Realm | realm-swift 20.x |
RealmStorage builds in Swift 6 language mode. Because language mode is per-target, your own code can stay on Swift 5.
MongoDB no longer distributes Realm. realm-swift 20.x is community-maintained and local-only — Atlas Device Sync was removed. RealmStorage has always been a local-persistence wrapper, so nothing it offered depended on sync.
⭐️ If you like what you see, star us on GitHub.
Found a bug, a typo, or something documented badly? Please open an issue. Contributions are welcome and appreciated — see CONTRIBUTING.md for how to get set up and what the tests expect.
MIT. See LICENSE.