Unleashing the real power of Core Data with the elegance and safety of Swift
Dependency managers
Contact
- Swift 5.9: iOS 13+ / macOS 10.15+ / watchOS 7.4+ / tvOS 13.0+
- Previously supported Swift versions: Swift 5.4, Swift 5.3, Swift 5.7
Upgrading from previous CoreStore versions? Check out the 🆕 features and make sure to read the Change logs.
CoreStore is part of the Swift Source Compatibility projects.
- TL;DR (a.k.a. sample codes)
- Why use CoreStore?
- Architecture
- CoreStore Tutorials (All of these have demos in the Demo app project!)
- Roadmap
- Installation
- Changesets
- Contact
- Who uses CoreStore?
- License
Pure-Swift models:
class Person: CoreStoreObject {
@Field.Stored("name")
var name: String = ""
@Field.Relationship("pets", inverse: \Dog.$master)
var pets: Set<Dog>
}
(Classic NSManagedObject
s also supported)
Setting-up with progressive migration support:
dataStack = DataStack(
xcodeModelName: "MyStore",
migrationChain: ["MyStore", "MyStoreV2", "MyStoreV3"]
)
Adding a store:
dataStack.addStorage(
SQLiteStore(fileName: "MyStore.sqlite"),
completion: { (result) -> Void in
// ...
}
)
Starting transactions:
dataStack.perform(
asynchronous: { (transaction) -> Void in
let person = transaction.create(Into<Person>())
person.name = "John Smith"
person.age = 42
},
completion: { (result) -> Void in
switch result {
case .success: print("success!")
case .failure(let error): print(error)
}
}
)
Fetching objects (simple):
let people = try dataStack.fetchAll(From<Person>())
Fetching objects (complex):
let people = try dataStack.fetchAll(
From<Person>()
.where(\.age > 30),
.orderBy(.ascending(\.name), .descending(.\age)),
.tweak({ $0.includesPendingChanges = false })
)
Querying values:
let maxAge = try dataStack.queryValue(
From<Person>()
.select(Int.self, .maximum(\.age))
)
But really, there's a reason I wrote this huge README. Read up on the details!
Check out the Demo app project for sample codes as well!
CoreStore was (and is) heavily shaped by real-world needs of developing data-dependent apps. It enforces safe and convenient Core Data usage while letting you take advantage of the industry's encouraged best practices.
- 🆕SwiftUI and Combine API utilities.
ListPublisher
s andObjectPublisher
s now have their@ListState
and@ObjectState
SwiftUI property wrappers. CombinePublisher
s are also available through theListPublisher.reactive
,ObjectPublisher.reactive
, andDataStack.reactive
namespaces. - Backwards-portable DiffableDataSources implementation!
UITableViews
andUICollectionViews
now have a new ally:ListPublisher
s provide diffable snapshots that make reloading animations very easy and very safe. Say goodbye toUITableViews
andUICollectionViews
reload errors! - 💎Tight design around Swift’s code elegance and type safety. CoreStore fully utilizes Swift's community-driven language features.
- 🚦Safer concurrency architecture. CoreStore makes it hard to fall into common concurrency mistakes. The main
NSManagedObjectContext
is strictly read-only, while all updates are done through serial transactions. (See Saving and processing transactions) - 🔍Clean fetching and querying API. Fetching objects is easy, but querying for raw aggregates (
min
,max
, etc.) and raw property values is now just as convenient. (See Fetching and querying) - 🔭Type-safe, easy to configure observers. You don't have to deal with the burden of setting up
NSFetchedResultsController
s and KVO. As an added bonus, list and object observable types all support multiple observers. This means you can have multiple view controllers efficiently share a single resource! (See Observing changes and notifications) - 📥Efficient importing utilities. Map your entities once with their corresponding import source (JSON for example), and importing from transactions becomes elegant. Uniquing is also done with an efficient find-and-replace algorithm. (See Importing data)
- 🗑Say goodbye to .xcdatamodeld files! While CoreStore supports
NSManagedObject
s, it offersCoreStoreObject
whose subclasses can declare type-safe properties all in Swift code without the need to maintain separate resource files for the models. As bonus, these special properties support custom types, and can be used to create type-safe keypaths and queries. (See Type-safeCoreStoreObject
s) - 🔗Progressive migrations. No need to think how to migrate from all previous model versions to your latest model. Just tell the
DataStack
the sequence of version strings (MigrationChain
s) and CoreStore will automatically use progressive migrations when needed. (See Migrations) - Easier custom migrations. Say goodbye to .xcmappingmodel files; CoreStore can now infer entity mappings when possible, while still allowing an easy way to write custom mappings. (See Migrations)
- 📝Plug-in your own logging framework. Although a default logger is built-in, all logging, asserting, and error reporting can be funneled to
CoreStoreLogger
protocol implementations. (See Logging and error reporting) - ⛓Heavy support for multiple persistent stores per data stack. CoreStore lets you manage separate stores in a single
DataStack
, just the way .xcdatamodeld configurations are designed to. CoreStore will also manage one stack by default, but you can create and manage as many as you need. (See Setting up) - 🎯Free to name entities and their class names independently. CoreStore gets around a restriction with other Core Data wrappers where the entity name should be the same as the
NSManagedObject
subclass name. CoreStore loads entity-to-class mappings from the managed object model file, so you can assign independent names for the entities and their class names. - 📙Full Documentation. No magic here; all public classes, functions, properties, etc. have detailed Apple Docs. This README also introduces a lot of concepts and explains a lot of CoreStore's behavior.
- ℹ️Informative (and pretty) logs. All CoreStore and Core Data-related types now have very informative and pretty print outputs! (See Logging and error reporting)
- 🛡More extensive Unit Tests. Extending CoreStore is safe without having to worry about breaking old behavior.
Have ideas that may benefit other Core Data users? Feature Requests are welcome!
For maximum safety and performance, CoreStore will enforce coding patterns and practices it was designed for. (Don't worry, it's not as scary as it sounds.) But it is advisable to understand the "magic" of CoreStore before you use it in your apps.
If you are already familiar with the inner workings of CoreData, here is a mapping of CoreStore
abstractions:
Core Data | CoreStore |
---|---|
NSPersistentContainer (.xcdatamodeld file) |
DataStack |
NSPersistentStoreDescription ("Configuration"s in the .xcdatamodeld file) |
StorageInterface implementations( InMemoryStore , SQLiteStore ) |
NSManagedObjectContext |
BaseDataTransaction subclasses( SynchronousDataTransaction , AsynchronousDataTransaction , UnsafeDataTransaction ) |
A lot of Core Data wrapper libraries set up their NSManagedObjectContext
s this way:
Nesting saves from child context to the root context ensures maximum data integrity between contexts without blocking the main queue. But in reality, merging contexts is still by far faster than saving contexts. CoreStore's DataStack
takes the best of both worlds by treating the main NSManagedObjectContext
as a read-only context (or "viewContext"), and only allows changes to be made within transactions on the child context:
This allows for a butter-smooth main thread, while still taking advantage of safe nested contexts.
The simplest way to initialize CoreStore is to add a default store to the default stack:
try CoreStoreDefaults.dataStack.addStorageAndWait()
This one-liner does the following:
- Triggers the lazy-initialization of
CoreStoreDefaults.dataStack
with a defaultDataStack
- Sets up the stack's
NSPersistentStoreCoordinator
, the root savingNSManagedObjectContext
, and the read-only mainNSManagedObjectContext
- Adds an
SQLiteStore
in the "Application Support/" directory (or the "Caches/" directory on tvOS) with the file name "[App bundle name].sqlite" - Creates and returns the
NSPersistentStore
instance on success, or anNSError
on failure
For most cases, this configuration is enough as it is. But for more hardcore settings, refer to this extensive example:
let dataStack = DataStack(
xcodeModelName: "MyModel", // loads from the "MyModel.xcdatamodeld" file
migrationChain: ["MyStore", "MyStoreV2", "MyStoreV3"] // model versions for progressive migrations
)
let migrationProgress = dataStack.addStorage(
SQLiteStore(
fileURL: sqliteFileURL, // set the target file URL for the sqlite file
configuration: "Config2", // use entities from the "Config2" configuration in the .xcdatamodeld file
localStorageOptions: .recreateStoreOnModelMismatch // if migration paths cannot be resolved, recreate the sqlite file
),
completion: { (result) -> Void in
switch result {
case .success(let storage):
print("Successfully added sqlite store: \(storage)")
case .failure(let error):
print("Failed adding sqlite store with error: \(error)")
}
}
)
CoreStoreDefaults.dataStack = dataStack // pass the dataStack to CoreStore for easier access later on
💡If you have never heard of "Configurations", you'll find them in your .xcdatamodeld file
In our sample code above, note that you don't need to do the CoreStoreDefaults.dataStack = dataStack
line. You can just as well hold a reference to the DataStack
like below and call all its instance methods directly:
class MyViewController: UIViewController {
let dataStack = DataStack(xcodeModelName: "MyModel") // keep reference to the stack
override func viewDidLoad() {
super.viewDidLoad()
do {
try self.dataStack.addStorageAndWait(SQLiteStore.self)
}
catch { // ...
}
}
func methodToBeCalledLaterOn() {
let objects = self.dataStack.fetchAll(From<MyEntity>())
print(objects)
}
}
💡By default, CoreStore will initialize
NSManagedObject
s from .xcdatamodeld files, but you can create models completely from source code usingCoreStoreObject
s andCoreStoreSchema
. To use this feature, refer to Type-safeCoreStoreObject
s.
Notice that in our previous examples, addStorageAndWait(_:)
and addStorage(_:completion:)
both accept either InMemoryStore
, or SQLiteStore
. These implement the StorageInterface
protocol.
The most basic StorageInterface
concrete type is the InMemoryStore
, which just stores objects in memory. Since InMemoryStore
s always start with a fresh empty data, they do not need any migration information.
try dataStack.addStorageAndWait(
InMemoryStore(
configuration: "Config2" // optional. Use entities from the "Config2" configuration in the .xcdatamodeld file
)
)
Asynchronous variant:
try dataStack.addStorage(
InMemoryStore(
configuration: "Config2
),
completion: { storage in
// ...
}
)
(A reactive-programming variant of this method is explained in detail in the section on DataStack
Combine publishers)
The most common StorageInterface
you will probably use is the SQLiteStore
, which saves data in a local SQLite file.
let migrationProgress = dataStack.addStorage(
SQLiteStore(
fileName: "MyStore.sqlite",
configuration: "Config2", // optional. Use entities from the "Config2" configuration in the .xcdatamodeld file
migrationMappingProviders: [Bundle.main], // optional. The bundles that contain required .xcmappingmodel files
localStorageOptions: .recreateStoreOnModelMismatch // optional. Provides settings that tells the DataStack how to setup the persistent store
),
completion: { /* ... */ }
)
Refer to the SQLiteStore.swift source documentation for detailed explanations for each of the default values.
CoreStore can decide the default values for these properties, so SQLiteStore
s can be initialized with no arguments:
try dataStack.addStorageAndWait(SQLiteStore())
(The asynchronous variant of this method is explained further in the next section on Migrations, and a reactive-programming variant in the section on DataStack
Combine publishers)
The file-related properties of SQLiteStore
are actually requirements of another protocol that it implements, the LocalStorage
protocol:
public protocol LocalStorage: StorageInterface {
var fileURL: NSURL { get }
var migrationMappingProviders: [SchemaMappingProvider] { get }
var localStorageOptions: LocalStorageOptions { get }
func dictionary(forOptions: LocalStorageOptions) -> [String: AnyObject]?
func cs_eraseStorageAndWait(metadata: [String: Any], soureModelHint: NSManagedObjectModel?) throws
}
If you have custom NSIncrementalStore
or NSAtomicStore
subclasses, you can implement this protocol and use it similarly to SQLiteStore
.
Model versions are now expressed as a first-class protocol, DynamicSchema
. CoreStore currently supports the following schema classes:
XcodeDataModelSchema
: a model version with entities loaded from a .xcdatamodeld file.CoreStoreSchema
: a model version created withCoreStoreObject
entities. (See Type-safeCoreStoreObject
s)UnsafeDataModelSchema
: a model version created with an existingNSManagedObjectModel
instance.
All the DynamicSchema
for all model versions are then collected within a single SchemaHistory
instance, which is then handed to the DataStack
. Here are some common use cases:
Multiple model versions grouped in a .xcdatamodeld file (Core Data standard method)
CoreStoreDefaults.dataStack = DataStack(
xcodeModelName: "MyModel",
bundle: Bundle.main,
migrationChain: ["MyAppModel", "MyAppModelV2", "MyAppModelV3", "MyAppModelV4"]
)
CoreStoreSchema
-based model version (No .xcdatamodeld file needed)
(For more details, see also Type-safe CoreStoreObject
s)
class Animal: CoreStoreObject {
// ...
}
class Dog: Animal {
// ...
}
class Person: CoreStoreObject {
// ...
}
CoreStoreDefaults.dataStack = DataStack(
CoreStoreSchema(
modelVersion: "V1",
entities: [
Entity<Animal>("Animal", isAbstract: true),
Entity<Dog>("Dog"),
Entity<Person>("Person")
]
)
)
Models in a .xcdatamodeld file during past app versions, but migrated to the new CoreStoreSchema
method
class Animal: CoreStoreObject {
// ...
}
class Dog: Animal {
// ...
}
class Person: CoreStoreObject {
// ...
}
let legacySchema = XcodeDataModelSchema.from(
modelName: "MyModel", // .xcdatamodeld name
bundle: bundle,
migrationChain: ["MyAppModel", "MyAppModelV2", "MyAppModelV3", "MyAppModelV4"]
)
let newSchema = CoreStoreSchema(
modelVersion: "V1",
entities: [
Entity<Animal>("Animal", isAbstract: true),
Entity<Dog>("Dog"),
Entity<Person>("Person")
]
)
CoreStoreDefaults.dataStack = DataStack(
schemaHistory: SchemaHistory(
legacySchema + [newSchema],
migrationChain: ["MyAppModel", "MyAppModelV2", "MyAppModelV3", "MyAppModelV4", "V1"]
)
)
CoreStoreSchema
-based model versions with progressive migration
typealias Animal = V2.Animal
typealias Dog = V2.Dog
typealias Person = V2.Person
enum V2 {
class Animal: CoreStoreObject {
// ...
}
class Dog: Animal {
// ...
}
class Person: CoreStoreObject {
// ...
}
}
enum V1 {
class Animal: CoreStoreObject {
// ...
}
class Dog: Animal {
// ...
}
class Person: CoreStoreObject {
// ...
}
}
CoreStoreDefaults.dataStack = DataStack(
CoreStoreSchema(
modelVersion: "V1",
entities: [
Entity<V1.Animal>("Animal", isAbstract: true),
Entity<V1.Dog>("Dog"),
Entity<V1.Person>("Person")
]
),
CoreStoreSchema(
modelVersion: "V2",
entities: [
Entity<V2.Animal>("Animal", isAbstract: true),
Entity<V2.Dog>("Dog"),
Entity<V2.Person>("Person")
]
),
migrationChain: ["V1", "V2"]
)
We have seen addStorageAndWait(...)
used to initialize our persistent store. As the method name's ~AndWait suffix suggests though, this method blocks so it should not do long tasks such as data migrations. In fact CoreStore will only attempt a synchronous lightweight migration if you explicitly provide the .allowSynchronousLightweightMigration
option:
try dataStack.addStorageAndWait(
SQLiteStore(
fileURL: sqliteFileURL,
localStorageOptions: .allowSynchronousLightweightMigration
)
}
if you do so, any model mismatch will be thrown as an error.
In general though, if migrations are expected the asynchronous variant addStorage(_:completion:)
method is recommended instead:
let migrationProgress: Progress? = try dataStack.addStorage(
SQLiteStore(
fileName: "MyStore.sqlite",
configuration: "Config2"
),
completion: { (result) -> Void in
switch result {
case .success(let storage):
print("Successfully added sqlite store: \(storage)")
case .failure(let error):
print("Failed adding sqlite store with error: \(error)")
}
}
)
The completion
block reports a SetupResult
that indicates success or failure.
(A reactive-programming variant of this method is explained further in the section on DataStack
Combine publishers)
Notice that this method also returns an optional Progress
. If nil
, no migrations are needed, thus progress reporting is unnecessary as well. If not nil
, you can use this to track migration progress by using standard KVO on the "fractionCompleted"
key, or by using a closure-based utility exposed in Progress+Convenience.swift:
migrationProgress?.setProgressHandler { [weak self] (progress) -> Void in
self?.progressView?.setProgress(Float(progress.fractionCompleted), animated: true)
self?.percentLabel?.text = progress.localizedDescription // "50% completed"
self?.stepLabel?.text = progress.localizedAdditionalDescription // "0 of 2"
}
This closure is executed on the main thread so UIKit and AppKit calls can be done safely.
By default, CoreStore uses Core Data's default automatic migration mechanism. In other words, CoreStore will try to migrate the existing persistent store until it matches the SchemaHistory
's currentModelVersion
. If no mapping model path is found from the store's version to the data model's version, CoreStore gives up and reports an error.
The DataStack
lets you specify hints on how to break a migration into several sub-migrations using a MigrationChain
. This is typically passed to the DataStack
initializer and will be applied to all stores added to the DataStack
with addSQLiteStore(...)
and its variants:
let dataStack = DataStack(migrationChain:
["MyAppModel", "MyAppModelV2", "MyAppModelV3", "MyAppModelV4"])
The most common usage is to pass in the model version (.xcdatamodeld version names for NSManagedObject
s, or the modelName
for CoreStoreSchema
s) in increasing order as above.
For more complex, non-linear migration paths, you can also pass in a version tree that maps the key-values to the source-destination versions:
let dataStack = DataStack(migrationChain: [
"MyAppModel": "MyAppModelV3",
"MyAppModelV2": "MyAppModelV4",
"MyAppModelV3": "MyAppModelV4"
])
This allows for different migration paths depending on the starting version. The example above resolves to the following paths:
- MyAppModel-MyAppModelV3-MyAppModelV4
- MyAppModelV2-MyAppModelV4
- MyAppModelV3-MyAppModelV4
Initializing with empty values (either nil
, []
, or [:]
) instructs the DataStack
to disable progressive migrations and revert to the default migration behavior (i.e. use the .xcdatamodeld's current version as the final version):
let dataStack = DataStack(migrationChain: nil)
The MigrationChain
is validated when passed to the DataStack
and unless it is empty, will raise an assertion if any of the following conditions are met:
- a version appears twice in an array
- a version appears twice as a key in a dictionary literal
- a loop is found in any of the paths
⚠️ Important: If aMigrationChain
is specified, the .xcdatamodeld's "Current Version" will be bypassed and theMigrationChain
's leafmost version will be theDataStack
's base model version.
Sometimes migrations are huge and you may want prior information so your app could display a loading screen, or to display a confirmation dialog to the user. For this, CoreStore provides a requiredMigrationsForStorage(_:)
method you can use to inspect a persistent store before you actually call addStorageAndWait(_:)
or addStorage(_:completion:)
:
do {
let storage = SQLiteStorage(fileName: "MyStore.sqlite")
let migrationTypes: [MigrationType] = try dataStack.requiredMigrationsForStorage(storage)
if migrationTypes.count > 1
|| (migrationTypes.filter { $0.isHeavyweightMigration }.count) > 0 {
// ... will migrate more than once. Show special waiting screen
}
else if migrationTypes.count > 0 {
// ... will migrate just once. Show simple activity indicator
}
else {
// ... Do nothing
}
dataStack.addStorage(storage, completion: { /* ... */ })
}
catch {
// ... either inspection of the store failed, or if no mapping model was found/inferred
}
requiredMigrationsForStorage(_:)
returns an array of MigrationType
s, where each item in the array may be either of the following values:
case lightweight(sourceVersion: String, destinationVersion: String)
case heavyweight(sourceVersion: String, destinationVersion: String)
Each MigrationType
indicates the migration type for each step in the MigrationChain
. Use these information as fit for your app.
CoreStore offers several ways to declare migration mappings:
CustomSchemaMappingProvider
: A mapping provider that infers mapping initially, but also accepts custom mappings for specified entities. This was added to support custom migrations withCoreStoreObject
s as well, but may also be used withNSManagedObject
s.XcodeSchemaMappingProvider
: A mapping provider which loads entity mappings from .xcmappingmodel files in a specifiedBundle
.InferredSchemaMappingProvider
: The default mapping provider which tries to infer model migration between twoDynamicSchema
versions either by searching all .xcmappingmodel files fromBundle.allBundles
, or by relying on lightweight migration if possible.
These mapping providers conform to SchemaMappingProvider
and can be passed to SQLiteStore
's initializer:
let dataStack = DataStack(migrationChain: ["MyAppModel", "MyAppModelV2", "MyAppModelV3", "MyAppModelV4"])
_ = try dataStack.addStorage(
SQLiteStore(
fileName: "MyStore.sqlite",
migrationMappingProviders: [
XcodeSchemaMappingProvider(from: "V1", to: "V2", mappingModelBundle: Bundle.main),
CustomSchemaMappingProvider(from: "V2", to: "V3", entityMappings: [.deleteEntity("Person") ])
]
),
completion: { (result) -> Void in
// ...
}
)
For version migrations present in the DataStack
's MigrationChain
but not handled by any of the SQLiteStore
's migrationMappingProviders
array, CoreStore will automatically try to use InferredSchemaMappingProvider
as fallback. Finally if the InferredSchemaMappingProvider
could not resolve any mapping, the migration will fail and the DataStack.addStorage(...)
method will report the failure.
For CustomSchemaMappingProvider
, more granular updates are supported through the dynamic objects UnsafeSourceObject
and UnsafeDestinationObject
. The example below allows the migration to conditionally ignore some objects:
let person_v2_to_v3_mapping = CustomSchemaMappingProvider(
from: "V2",
to: "V3",
entityMappings: [
.transformEntity(
sourceEntity: "Person",
destinationEntity: "Person",
transformer: { (sourceObject: UnsafeSourceObject, createDestinationObject: () -> UnsafeDestinationObject) in
if (sourceObject["isVeryOldAccount"] as! Bool?) == true {
return // this account is too old, don't migrate
}
// migrate the rest
let destinationObject = createDestinationObject()
destinationObject.enumerateAttributes { (attribute, sourceAttribute) in
if let sourceAttribute = sourceAttribute {
destinationObject[attribute] = sourceObject[sourceAttribute]
}
}
)
]
)
SQLiteStore(
fileName: "MyStore.sqlite",
migrationMappingProviders: [person_v2_to_v3_mapping]
)
The UnsafeSourceObject
is a read-only proxy for an object existing in the source model version. The UnsafeDestinationObject
is a read-write object that is inserted (optionally) to the destination model version. Both classes' properties are accessed through key-value-coding.
To ensure deterministic state for objects in the read-only NSManagedObjectContext
, CoreStore does not expose API's for updating and saving directly from the main context (or any other context for that matter.) Instead, you spawn transactions from DataStack
instances:
let dataStack = self.dataStack
dataStack.perform(
asynchronous: { (transaction) -> Void in
// make changes
},
completion: { (result) -> Void in
// ...
}
)
Transaction closures automatically save changes once the closures completes. To cancel and rollback a transaction, throw a CoreStoreError.userCancelled
from inside the closure by calling try transaction.cancel()
:
dataStack.perform(
asynchronous: { (transaction) -> Void in
// ...
if shouldCancel {
try transaction.cancel()
}
// ...
},
completion: { (result) -> Void in
if case .failure(.userCancelled) = result {
// ... cancelled
}
}
)
⚠️ Important: Never usetry?
ortry!
on atransaction.cancel()
call. Always usetry
. Usingtry?
will swallow the cancellation and the transaction will proceed to save as normal. Usingtry!
will crash the app astransaction.cancel()
will always throw an error.
The examples above use perform(asynchronous:...)
, but there are actually 3 types of transactions at your disposal: asynchronous, synchronous, and unsafe.
are spawned from perform(asynchronous:...)
. This method returns immediately and executes its closure from a background serial queue. The return value for the closure is declared as a generic type, so any value returned from the closure can be passed to the completion result:
dataStack.perform(
asynchronous: { (transaction) -> Bool in
// make changes
return transaction.hasChanges
},
completion: { (result) -> Void in
switch result {
case .success(let hasChanges): print("success! Has changes? \(hasChanges)")
case .failure(let error): print(error)
}
}
)
The success and failure can also be declared as separate handlers:
dataStack.perform(
asynchronous: { (transaction) -> Int in
// make changes
return transaction.delete(objects)
},
success: { (numberOfDeletedObjects: Int) -> Void in
print("success! Deleted \(numberOfDeletedObjects) objects")
},
failure: { (error) -> Void in
print(error)
}
)
⚠️ Be careful when returningNSManagedObject
s orCoreStoreObject
s from the transaction closure. Those instances are for the transaction's use only. See Passing objects safely.
Transactions created from perform(asynchronous:...)
are instances of AsynchronousDataTransaction
.
are created from perform(synchronous:...)
. While the syntax is similar to its asynchronous counterpart, perform(synchronous:...)
waits for its tr