How to Observe Data Changes in SwiftData Using Persistent History Tracking

fatbobman ( 东坡肘子)
ITNEXT
Published in
8 min readNov 2, 2023

--

Photo by Markus Spiske on Unsplash

When there are changes in the database, Persistent History Tracking will send notifications to the subscribers. Developers can use this opportunity to respond to modifications made to the same database, including other applications, widgets (in the same App Group), and batch processing tasks. Since SwiftData integrates support for Persistent History Tracking, there is no need to write additional code. Subscription notifications and transaction merges will be automatically handled by SwiftData.

However, in some cases, developers may want to manually respond to transactions tracked by Persistent History Tracking for more flexibility. This article will explain how to observe specific data changes through Persistent History Tracking in SwiftData.

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

Why should we self-responsively handle persistent history tracking transactions?

SwiftData integrates support for persistent history tracking, allowing views to promptly and accurately respond to data changes. This is helpful when modifying data from the network, other applications, or widgets. However, in certain situations, developers may need to manually handle persistent history tracking transactions beyond just the view layer.

The reasons for manually handling persistent history tracking transactions are as follows:

  1. Integration with other functionalities: SwiftData may not fully integrate with certain features or frameworks, such as NSCoreDataCoreSpotlightDelegate. In such cases, developers need to handle transactions themselves to adjust the display in Spotlight.
  2. Performing actions on specific data changes: When data changes, developers may need to perform additional logic or operations. By manually responding, they can selectively execute actions only for the changed data, reducing operation costs.
  3. Extending functionality: Manual response provides developers with greater flexibility and extensibility to implement functions that SwiftData currently cannot achieve.

In conclusion, manually responding to persistent history tracking transactions allows developers to have more control in handling integration issues, specific data changes, and extending functionality. This enables developers to better utilize persistent history tracking to meet various requirements.

Persistent History Tracking Handling in Core Data

Handling persistent history tracking in Core Data involves the following steps:

  1. Set different transaction authors for different data operators (applications, widgets): You can assign a unique name to each data operator (application, widget) using the transactionAuthor property. This allows for differentiation between different data operators and ensures that each operator's transactions can be properly identified.
  2. Save the timestamp of the last fetched transaction for each data operator in a shared container: You can use UserDefaults to save the timestamp of the last fetched transaction for each data operator at a specific location in the App Group's shared container. This allows for retrieving all newly generated persistent history tracking transactions since the last merge based on their timestamps.
  3. Enable persistent history tracking and respond to notifications: In the Core Data stack, you need to enable persistent history tracking and register as an observer for notifications related to persistent history tracking.
  4. Retrieve newly generated persistent history tracking transactions: Upon receiving a persistent history tracking notification, you can fetch newly generated transactions from the persistent history tracking store based on the timestamp of the last fetched transaction. Typically, you only need to retrieve transactions generated by data operators other than the current one (application, widget).
  5. Process the transactions: Handle the retrieved persistent history tracking transactions, such as merging the changes into the current view context.
  6. Update the last fetched timestamp: After processing the transactions, set the timestamp of the latest fetched transaction as the last fetched timestamp to ensure that only new transactions are fetched in the next retrieval.
  7. Clear merged transactions: Once all data operators have completed processing the transactions, you can clear the merged transactions as needed.

NSPersistentCloudContainer automatically merges synchronization transactions from the network, so developers do not need to handle it themselves.

Read the article “Using Persistent History Tracking in CoreData” for a complete implementation details.

Differences in Using Persistent History Tracking in SwiftData

In SwiftData, the use of persistent history tracking is similar to Core Data, but there are also some differences:

  1. View-level data merging: SwiftData can automatically handle view-level data merging, so developers do not need to manually handle transaction merging operations.
  2. Transaction clearance: In order to ensure that other members of the same App Group using SwiftData can correctly obtain transactions, transactions that have already been processed are not cleared.
  3. Saving timestamps: Each member of the App Group using SwiftData only needs to save their last retrieved timestamp individually, without the need to save it uniformly in the shared container.
  4. Transaction processing logic: Due to the completely different concurrency programming approach adopted by SwiftData, the transaction processing logic is placed in a ModelActor. This instance is responsible for handling the retrieval and processing of persistent history tracking transactions.
  5. fetchRequest in NSPersistentHistoryChangeRequest is nil: In SwiftData, the fetchRequest in NSPersistentHistoryChangeRequest created through fetchHistory is nil, so transactions cannot be filtered using predicates. The filtering process will be done in memory.
  6. Type conversion: The data information contained in the persistent history tracking transaction is NSManagedObjectID, which needs to be converted to PersistentIdentifier using SwiftDataKit for further processing in SwiftData.

In the following specific implementation, some considerations will be explained in more detail.

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to Fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

Implementation Details

You can find the complete demo code here.

Declare DataProvider

First, we will declare a DataProvider that includes ModelContainer and ModelActor for handling persistent history tracking:

import Foundation
import SwiftData
import SwiftDataKit

public final class DataProvider: @unchecked Sendable {
public var container: ModelContainer
// a model actor to handle persistent history tracking transaction
private var monitor: DBMonitor?
public static let share = DataProvider(inMemory: false, enableMonitor: true)
public static let preview = DataProvider(inMemory: true, enableMonitor: false)
init(inMemory: Bool = false, enableMonitor: Bool = false) {
let schema = Schema([
Item.self,
])
let modelConfiguration: ModelConfiguration
modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: inMemory)
do {
let container = try ModelContainer(for: schema, configurations: [modelConfiguration])
self.container = container
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}
}

Since both types of the only two stored properties in DataProvider conform to the Sendable protocol, I declare DataProvider as Sendable as well.

Naming the transactionAuthor for ModelContext

In the demonstration, to only handle transactions generated by the mainContext of the current application, we need to name the transactionAuthor for ModelContext.

extension DataProvider {
@MainActor
private func setAuthor(container: ModelContainer, authorName: String) {
container.mainContext.managedObjectContext?.transactionAuthor = authorName
}
}

// in init
do {
let container = try ModelContainer(for: schema, configurations: [modelConfiguration])
self.container = container
// Set transactionAuthor of mainContext to mainApp
Task {
await setAuthor(container: container, authorName: "mainApp")
}
} catch {
fatalError("Could not create ModelContainer: \(error)")
}

Declare ModelActor for handling persistent history tracking

SwiftData adopts a safer and more elegant approach to concurrent programming by placing all code related to persistent history tracking in a ModelActor.

Read the article on Concurrent Programming in SwiftData to master the new methods of concurrent programming.

import Foundation
import SwiftData
import SwiftDataKit
import Combine
import CoreData

@ModelActor
public actor DBMonitor {
private var cancellable: AnyCancellable?
// last history transaction timestamp
private var lastHistoryTransactionTimestamp: Date {
get {
UserDefaults.standard.object(forKey: "lastHistoryTransactionTimestamp") as? Date ?? Date.distantPast
}
set {
UserDefaults.standard.setValue(newValue, forKey: "lastHistoryTransactionTimestamp")
}
}
}
extension DBMonitor {
// Respond to persistent history tracking notifications
public func register(excludeAuthors: [String] = []) {
guard let coordinator = modelContext.coordinator else { return }
cancellable = NotificationCenter.default.publisher(
for: .NSPersistentStoreRemoteChange,
object: coordinator
)
.map { _ in () }
.prepend(())
.sink { _ in
self.processor(excludeAuthors: excludeAuthors)
}
}
// After receiving the notification, process the transaction
private func processor(excludeAuthors: [String]) {
// Get all transactions
let transactions = fetchTransaction()
// Save the timestamp of the latest transaction
lastHistoryTransactionTimestamp = transactions.max { $1.timestamp > $0.timestamp }?.timestamp ?? .now
// Filter transactions to exclude transactions generated by excludeAuthors
for transaction in transactions where !excludeAuthors.contains([transaction.author ?? ""]) {
for change in transaction.changes ?? [] {
// Send transaction to processing unit
changeHandler(change)
}
}
}
// Fetch all newly generated transactions since the last processing
private func fetchTransaction() -> [NSPersistentHistoryTransaction] {
let timestamp = lastHistoryTransactionTimestamp
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: timestamp)
// In SwiftData, the fetchRequest.fetchRequest created by fetchHistory is nil and predicate cannot be set.
guard let historyResult = try? modelContext.managedObjectContext?.execute(fetchRequest) as? NSPersistentHistoryResult,
let transactions = historyResult.result as? [NSPersistentHistoryTransaction]
else {
return []
}
return transactions
}
// Process filtered transactions
private func changeHandler(_ change: NSPersistentHistoryChange) {
// Convert NSManagedObjectID to PersistentIdentifier via SwiftDataKit
if let id = change.changedObjectID.persistentIdentifier {
let author = change.transaction?.author ?? "unknown"
let changeType = change.changeType
print("author:\(author) changeType:\(changeType)")
print(id)
}
}
}

In DBMonitor, we only handle transactions that are not generated by members of the excludeAuthors list. You can set excludeAuthors as needed, such as adding all transactionAuthors of the current App’s modelContext to it.

To enable DBMonitor in the DataProvider:

// DataProvider init
do {
let container = try ModelContainer(for: schema, configurations: [modelConfiguration])
self.container = container
Task {
await setAuthor(container: container, authorName: "mainApp")
}
// Create DBMonitor to handle persistent historical tracking transactions
if enableMonitor {
Task.detached {
self.monitor = DBMonitor(modelContainer: container)
await self.monitor?.register(excludeAuthors: ["mainApp"])
}
}
} catch {
fatalError("Could not create ModelContainer: \(error)")
}

In Xcode, when the Strict Concurrency Checking setting is set to Complete (to prepare for Swift 6 and perform strict scrutiny of concurrent code), if the DataProvider does not conform to Sendable, you will receive the following warning message:

Capture of 'self' with non-sendable type 'DataProvider' in a `@Sendable` closure

Testing

So far, we have completed the work of responding to persistent history tracking in SwiftData. To verify the results, we will create a new ModelActor to create new data through it (without using mainContext).

@ModelActor
actor PrivateDataHandler {
func setAuthorName(name: String) {
modelContext.managedObjectContext?.transactionAuthor = name
}

func newItem() {
let item = Item(timestamp: .now)
modelContext.insert(item)
try? modelContext.save()
}
}

In the ContentView, add a button to create data through PrivateDataHandler:

ToolbarItem(placement: .topBarLeading) {
Button {
let container = modelContext.container
Task.detached {
let handler = PrivateDataHandler(modelContainer: container)
// Set transactionAuthor of PrivateDataHandler's modelContext to Private, you can also not set it
await handler.setAuthorName(name: "Private")
await handler.newItem()
}
} label: {
Text("New Item")
}
}

After running the application, click the + button in the upper right corner. Because the new data is created through the mainContext (with mainApp excluded from the excludeAuthors list), the corresponding transaction will not be sent to the changeHandler. However, data created through the "New Item" button in the upper left corner, which corresponds to the modelContext not included in the excludeAuthors list, will have the changeHandler print the corresponding information.

Conclusions

Handling persistent history tracking transactions on our own can allow us to achieve more advanced features in SwiftData, which may help developers who want to use SwiftData but still have concerns about limited functionality.

A Chinese version of this post is available here.

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.


Want to Connect?


@fatbobman on Twitter.

--

--

Blogger | Sharing articles at https://fatbobman.com | Publisher of a weekly newsletter on Swift at http://https://weekly.fatbobman.com