Why use record-level persistence boundaries in our code?
struct RecordChanges: Sendable {
private(set) var upserts: [String: Data] = [:]
private(set) var removals: Set<String> = []
var isEmpty: Bool { upserts.isEmpty && removals.isEmpty }
subscript(key: String) -> Data? {
get { upserts[key] }
set {
if let newValue { upserts[key] = newValue; removals.remove(key) }
else { upserts.removeValue(forKey: key); removals.insert(key) }
}
}
func applying(to existing: [String: Data]) -> [String: Data] {
var rows = existing
for key in removals { rows.removeValue(forKey: key) }
rows.merge(upserts) { _, new in new }
return rows
}
}This complete value type comes from Cuentiva, a Spanish-learning app that remembers reading progress, vocabulary and learning activity. It describes specific records to insert or update and specific keys to remove. It does not mean “replace everything currently saved with my screen’s copy”.
The distinction matters when a learner advances in a book while another operation updates a different part of their progress. A narrow change should not erase unrelated records simply because its caller started with an older snapshot.
1. The approach
private static func map<T: Codable & Equatable>(
_ name: String, _ current: [String: T], _ old: [String: T], into rows: inout RecordChanges
) throws {
for key in old.keys where current[key] == nil { rows[name + "/" + key] = nil }
for (key, value) in current where old[key] != value { rows[name + "/" + key] = try RecordCoding.encode(value) }
}This helper belongs to Cuentiva’s ProgressRecords encoder. It compares the current and previous values of a named dictionary. Missing keys become removals; changed values become upserts. A reading position can therefore be represented by its own key rather than forcing a rewrite of the whole learner archive.
RecordChanges makes the two intentions mutually exclusive for each key. Assign data and the key leaves removals; assign nil and it leaves upserts. That avoids contradictory instructions at the storage boundary.
CFA’s documented rationale: persist the intended record-level change by stable identity. A whole-collection replacement belongs to an explicit replacement or import operation, not to every ordinary edit. This policy is about preserving unrelated data, not choosing a fashionable persistence framework.
2. The problem it solves
@Model final class StoredRecord {
@Attribute(.unique) var identity: String
var collection: String
var key: String
var payload: Data
init(collection: String, key: String, payload: Data) {
self.identity = collection + ":" + key
self.collection = collection
self.key = key
self.payload = payload
}
}The database gives each record an identity combining collection and key. That lets storage distinguish, for example, one progress entry from another collection’s record with a similar key. The schema is part of the saved-data contract; changing those fields or their encoding is not a cosmetic rename.
If two callers each send a full copy of an archive, the later write can erase changes absent from its copy. A database actor can serialize those writes and still faithfully perform that unwanted replacement. Serialization protects access; it does not decide which records a command was entitled to change.
Record-level changes reduce that risk for unrelated keys. They do not automatically resolve two callers editing the same key. The feature still needs a deliberate whole-operation ordering or conflict policy.
3. Why it works
func apply(_ collection: String, changes: RecordChanges) throws {
guard !changes.isEmpty else { return }
modelContext.autosaveEnabled = false
do {
for key in changes.removals {
if let record = try record(collection, key: key) { modelContext.delete(record) }
}
for (key, payload) in changes.upserts {
if let record = try record(collection, key: key) {
if record.payload != payload { record.payload = payload }
} else {
modelContext.insert(StoredRecord(collection: collection, key: key, payload: payload))
}
}
if modelContext.hasChanges { try commit() }
} catch {
modelContext.rollback()
throw error
}
}This complete method runs inside Cuentiva’s @ModelActor DatabaseWorker. It removes only named records and upserts only supplied payloads. It saves after the changes, rolls the context back if an error occurs, and rethrows that error. It never scans the caller’s incomplete snapshot and treats every absent record as deleted.
Apple’s ModelActor boundary supplies an isolated model context. Managed objects stay inside that owner. Cuentiva sends values such as RecordChanges across the boundary rather than handing SwiftData objects to its screens.
func save(_ progress: LearnerProgress) async throws {
guard progress.schemaVersion == 1 else { throw AppFailure.unavailable("Unsupported progress version.") }
if savedProgress == nil { _ = try await load() }
if hasStoredProgress {
let changes = try ProgressRecords.changes(progress, previous: savedProgress)
try await store.apply("progress", changes: changes)
} else {
try await store.replace("progress", values: ProgressRecords.encode(progress))
hasStoredProgress = true
}
savedProgress = progress
}The Feature owns the authoritative application state and decides which changes are accepted. The database worker owns persistence machinery and stored representations. A saved record, decoded value or internal cache can represent the same information at another boundary without becoming a competing business owner. Screens read the Feature rather than independently choosing between those representations.
The progress repository uses incremental changes after initial storage has been established. The initial path deliberately seeds the collection; the later path applies a difference. Only after the awaited storage operation succeeds does it update savedProgress. A thrown save is not reported as confirmed progress.
4. Advantages of using it
private func worker() async throws -> DatabaseWorker {
if let opening { return try await opening.value }
let url = url
let task = Task { try await StoreOpener.shared.open(at: url) }
opening = task
do { return try await task.value } catch {
opening = nil
throw error
}
}This method belongs to Cuentiva’s SwiftDataStore. It shares one opening operation and obtains the database worker lazily. Features can share the storage owner without AppModel opening a persistent container in its initializer. An opening failure is thrown through the request, and a later request can retry.
Editorial trade-off: record identities, migrations and change encoding add responsibility. Use a simple file cache when the data really is one replaceable remote snapshot. Cuentiva’s growing, editable learner records justify a more structured store; that does not mean every API response needs SwiftData.
Test the boundary with an existing unrelated record, a failed save, a successful retry and a reopened store. Those checks establish more than a successful encode alone. Preserve the shipped path and schema while improving ownership: reorganizing features is not permission to move or reset someone’s saved learning history.
Try this with the CFA AI Toolkit for iOS:
Using CFA for iOS, review this persistence boundary while preserving existing stores and records.
Scope: code from the linked app revisions; smaller excerpts belong to their containing types and targets. The articles distinguish older implementations from current CFA conventions. Source verification is not a full app build or device test. Suggested checks are guidance, not claims they ran against your application.