How to Observe Data Changes in SwiftData using Persistent History Tracking

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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.

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.

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:

Swift
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.

Swift
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.

Swift
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:

Swift
// 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:

Swift
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).

Swift
@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:

Swift
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.

https://cdn.fatbobman.com/swiftData-persistent-history-tracking-demo_2023-10-27_21.50.55.2023-10-27%2021_52_22.gif

Summary

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.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed