Persistent History Tracking
Persistent History Tracking
Section titled “Persistent History Tracking”Persistent history tracking enables Core Data to track changes across contexts, app extensions, and batch operations. This is essential for keeping your UI synchronized and supporting multi-target apps.
Why Persistent History Tracking?
Section titled “Why Persistent History Tracking?”Without persistent history tracking:
- Batch operations don’t update UI
- App extensions can’t notify main app of changes
- Multiple contexts don’t stay synchronized
With persistent history tracking:
- All changes are recorded in a transaction log
- Changes can be merged into any context
- Works across app targets (main app, extensions, etc.)
Enabling Persistent History Tracking
Section titled “Enabling Persistent History Tracking”In NSPersistentContainer
Section titled “In NSPersistentContainer”class PersistentContainer: NSPersistentContainer { override init(name: String, managedObjectModel model: NSManagedObjectModel) { super.init(name: name, managedObjectModel: model)
guard let description = persistentStoreDescriptions.first else { fatalError("No store description") }
// Enable persistent history tracking description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// Enable remote change notifications description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
loadPersistentStores { description, error in if let error = error { fatalError("Failed to load store: \(error)") } } }}For App Groups (Extensions)
Section titled “For App Groups (Extensions)”let storeURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: "group.com.example.app")?.appendingPathComponent("Shared.sqlite")
let description = NSPersistentStoreDescription(url: storeURL!)description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.persistentStoreDescriptions = [description]The Four Components
Section titled “The Four Components”Persistent history tracking typically involves four components:
- Observer - Listens for remote change notifications
- Fetcher - Retrieves relevant transactions
- Merger - Merges transactions into view context
- Cleaner - Removes old transactions
1. Observer: Listening for Changes
Section titled “1. Observer: Listening for Changes”final class PersistentHistoryObserver { private let coordinator: NSPersistentStoreCoordinator private let historyContext: NSManagedObjectContext private let merger: PersistentHistoryMerger
init(container: NSPersistentContainer, viewContext: NSManagedObjectContext) { self.coordinator = container.persistentStoreCoordinator self.historyContext = container.newBackgroundContext() self.historyContext.name = "PersistentHistoryContext" self.historyContext.transactionAuthor = "PersistentHistory" self.merger = PersistentHistoryMerger(historyContext: historyContext, viewContext: viewContext)
NotificationCenter.default.addObserver( self, selector: #selector(processStoreRemoteChanges), name: .NSPersistentStoreRemoteChange, object: coordinator ) }
@objc private func processStoreRemoteChanges(_ notification: Notification) { merger.merge() }
deinit { NotificationCenter.default.removeObserver(self) }}2. Fetcher: Retrieving Transactions
Section titled “2. Fetcher: Retrieving Transactions”class PersistentHistoryFetcher { private let context: NSManagedObjectContext private let lastToken: NSPersistentHistoryToken?
init(context: NSManagedObjectContext, lastToken: NSPersistentHistoryToken?) { self.context = context self.lastToken = lastToken }
func fetch() throws -> [NSPersistentHistoryTransaction] { let fetchRequest = createFetchRequest()
guard let historyResult = try context.execute(fetchRequest) as? NSPersistentHistoryResult, let transactions = historyResult.result as? [NSPersistentHistoryTransaction] else { return [] }
return transactions }
private func createFetchRequest() -> NSPersistentHistoryChangeRequest { let request: NSPersistentHistoryChangeRequest
if let token = lastToken { request = NSPersistentHistoryChangeRequest.fetchHistory(after: token) } else { request = NSPersistentHistoryChangeRequest.fetchHistory(after: Date.distantPast) }
// Filter out transactions from this app target if let fetchRequest = request.fetchRequest { fetchRequest.predicate = NSPredicate( format: "author != %@", "MainApp" // Your app's transaction author ) }
return request }}3. Merger: Applying Changes
Section titled “3. Merger: Applying Changes”final class PersistentHistoryMerger { private let historyContext: NSManagedObjectContext private let viewContext: NSManagedObjectContext private var lastToken: NSPersistentHistoryToken?
init(historyContext: NSManagedObjectContext, viewContext: NSManagedObjectContext) { self.historyContext = historyContext self.viewContext = viewContext self.lastToken = loadLastToken() }
func merge() { historyContext.perform { do { let fetcher = PersistentHistoryFetcher( context: self.historyContext, lastToken: self.lastToken )
let transactions = try fetcher.fetch() guard !transactions.isEmpty else { return }
self.viewContext.perform { self.mergeTransactions(transactions) }
if let newToken = transactions.last?.token { self.lastToken = newToken self.saveLastToken(newToken) } } catch { print("Failed to merge history: \(error)") } } }
private func mergeTransactions(_ transactions: [NSPersistentHistoryTransaction]) { for transaction in transactions { guard let userInfo = transaction.objectIDNotification().userInfo else { continue } NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [viewContext]) } }
private func loadLastToken() -> NSPersistentHistoryToken? { guard let data = UserDefaults.standard.data(forKey: "lastHistoryToken") else { return nil } return try? NSKeyedUnarchiver.unarchivedObject( ofClass: NSPersistentHistoryToken.self, from: data ) }
private func saveLastToken(_ token: NSPersistentHistoryToken) { if let data = try? NSKeyedArchiver.archivedData( withRootObject: token, requiringSecureCoding: true ) { UserDefaults.standard.set(data, forKey: "lastHistoryToken") } }}4. Cleaner: Removing Old Transactions
Section titled “4. Cleaner: Removing Old Transactions”class PersistentHistoryCleaner { private let context: NSManagedObjectContext private let targets: [AppTarget]
enum AppTarget { case mainApp case shareExtension case widgetExtension
var lastTokenKey: String { switch self { case .mainApp: return "mainApp.lastHistoryToken" case .shareExtension: return "shareExtension.lastHistoryToken" case .widgetExtension: return "widgetExtension.lastHistoryToken" } } }
init(context: NSManagedObjectContext, targets: [AppTarget]) { self.context = context self.targets = targets }
func clean() { context.perform { // Find the oldest token across all targets guard let oldestToken = self.findOldestToken() else { return }
// Delete history before that token let deleteRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: oldestToken)
do { try self.context.execute(deleteRequest) } catch { print("Failed to clean history: \(error)") } } }
private func findOldestToken() -> NSPersistentHistoryToken? { var oldestDate: Date? var oldestToken: NSPersistentHistoryToken?
for target in targets { guard let token = loadToken(for: target) else { continue }
// Get timestamp from token (requires fetching transaction) let historyRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: token) historyRequest.fetchRequest?.fetchLimit = 1
guard let result = try? context.execute(historyRequest) as? NSPersistentHistoryResult, let transactions = result.result as? [NSPersistentHistoryTransaction], let transaction = transactions.first else { continue }
let date = transaction.timestamp if oldestDate == nil || date < oldestDate! { oldestDate = date oldestToken = token } }
return oldestToken }
private func loadToken(for target: AppTarget) -> NSPersistentHistoryToken? { guard let data = UserDefaults.standard.data(forKey: target.lastTokenKey) else { return nil } return try? NSKeyedUnarchiver.unarchivedObject( ofClass: NSPersistentHistoryToken.self, from: data ) }}Complete Integration Example
Section titled “Complete Integration Example”class CoreDataStack { static let shared = CoreDataStack()
lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "Model")
// Configure store guard let description = container.persistentStoreDescriptions.first else { fatalError("No store description") }
// Enable persistent history tracking description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.loadPersistentStores { description, error in if let error = error { fatalError("Failed to load store: \(error)") }
self.setupHistoryTracking(container: container) }
// Configure view context container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.name = "ViewContext" container.viewContext.transactionAuthor = "MainApp"
return container }()
private var historyObserver: PersistentHistoryObserver?
private init() {}
private func setupHistoryTracking(container: NSPersistentContainer) { historyObserver = PersistentHistoryObserver(container: container, viewContext: container.viewContext) cleanHistoryPeriodically(container: container) }
private func cleanHistoryPeriodically(container: NSPersistentContainer) { Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { _ in let context = container.newBackgroundContext() let cleaner = PersistentHistoryCleaner( context: context, targets: [.mainApp, .shareExtension] ) cleaner.clean() } }}Transaction Authors
Section titled “Transaction Authors”Set unique transaction authors for each app target:
// Main appviewContext.transactionAuthor = "MainApp"
// Share extensionviewContext.transactionAuthor = "ShareExtension"
// Widget extensionviewContext.transactionAuthor = "WidgetExtension"Why this matters:
- Filter out your own transactions (avoid redundant merges)
- Identify which target made changes
- Debug multi-target issues
Filtering Transactions
Section titled “Filtering Transactions”By Author
Section titled “By Author”let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)if let request = fetchRequest.fetchRequest { request.predicate = NSPredicate(format: "author != %@", "MainApp")}By Date
Section titled “By Date”let cutoffDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())!let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: cutoffDate)By Entity
Section titled “By Entity”let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)if let request = fetchRequest.fetchRequest { request.predicate = NSPredicate(format: "ANY changes.changedObjectID.entity.name == %@", "Article")}Batch Operations Integration
Section titled “Batch Operations Integration”Persistent history tracking is required for batch operations to update the UI:
// 1. Enable persistent history trackingdescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// 2. Perform batch operationlet context = container.newBackgroundContext()context.perform { let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in // Insert logic return false } try? context.execute(batchInsert)}
// 3. UI updates automatically via persistent history tracking// The observer detects the change and merges it into the view contextTesting Persistent History
Section titled “Testing Persistent History”func testPersistentHistory() throws { // Enable persistent history let description = container.persistentStoreDescriptions.first! description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// Create object in background let backgroundContext = container.newBackgroundContext() backgroundContext.transactionAuthor = "Test"
let expectation = XCTestExpectation(description: "Save")
backgroundContext.perform { let article = Article(context: backgroundContext) article.name = "Test" try? backgroundContext.save() expectation.fulfill() }
wait(for: [expectation], timeout: 5.0)
// Fetch history let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: Date.distantPast) let result = try container.viewContext.execute(fetchRequest) as? NSPersistentHistoryResult let transactions = result?.result as? [NSPersistentHistoryTransaction]
XCTAssertNotNil(transactions) XCTAssertFalse(transactions!.isEmpty)}Common Pitfalls
Section titled “Common Pitfalls”❌ Not Enabling Remote Change Notifications
Section titled “❌ Not Enabling Remote Change Notifications”// Only this isn't enoughdescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// Need both!description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)❌ Not Filtering Own Transactions
Section titled “❌ Not Filtering Own Transactions”// Merges own transactions (redundant)let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)❌ Not Cleaning Old Transactions
Section titled “❌ Not Cleaning Old Transactions”// History grows unbounded, wastes space// Always implement cleaning!❌ Not Setting Transaction Authors
Section titled “❌ Not Setting Transaction Authors”// Can't filter transactions by sourcecontext.transactionAuthor = nil // Bad!✅ Correct Approach
Section titled “✅ Correct Approach”// 1. Enable both optionsdescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
// 2. Set transaction authorcontext.transactionAuthor = "MainApp"
// 3. Filter own transactionsfetchRequest.predicate = NSPredicate(format: "author != %@", "MainApp")
// 4. Clean periodicallylet cleaner = PersistentHistoryCleaner(context: context, targets: [.mainApp, .shareExtension])cleaner.clean()Performance Considerations
Section titled “Performance Considerations”Clean History Regularly
Section titled “Clean History Regularly”// Clean dailyTimer.scheduledTimer(withTimeInterval: 86400, repeats: true) { _ in cleaner.clean()}
// Or on app launchfunc applicationDidFinishLaunching() { cleaner.clean()}Limit Fetch Range
Section titled “Limit Fetch Range”// Don't fetch all historylet sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date())!let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: sevenDaysAgo)Batch Merge Changes
Section titled “Batch Merge Changes”// Merge multiple transactions at oncelet transactions = try fetcher.fetch()for transaction in transactions { let userInfo = transaction.objectIDNotification().userInfo NSManagedObjectContext.mergeChanges( fromRemoteContextSave: userInfo!, into: [viewContext] )}Summary
Section titled “Summary”- Enable persistent history tracking - Required for batch operations and multi-target apps
- Enable remote change notifications - Required for cross-context updates
- Set transaction authors - Identify change sources
- Filter own transactions - Avoid redundant merges
- Implement all four components - Observer, Fetcher, Merger, Cleaner
- Clean history regularly - Prevent unbounded growth
- Use with batch operations - Essential for UI updates
- Test thoroughly - Verify history tracking works across targets