Swift 6 ready • Actor-based • Fully concurrent • Type-safe
A modern, production-ready library for handling Core Data's Persistent History Tracking with full Swift 6 concurrency support.
Version 2 is a complete rewrite with modern Swift concurrency:
- ✅ Full Swift 6 Compliance - Concurrency-safe design tuned for Swift 6
- ✅ Actor-Based Architecture - Thread-safe by design with
HookRegistryActorandTransactionProcessorActor - ✅ Zero Memory Leaks - No retain cycles, properly managed lifecycle
- ✅ Data Race Free - Comprehensive concurrency testing with Swift Testing
- ✅ Hook System - Powerful Observer and Merge Hooks for custom behaviors
- ✅ Modern API - Async/await throughout, UUID-based hook management
Migration from V1: V2 declares iOS 13+, macOS 10.15+, macCatalyst 13+, tvOS 13+, watchOS 6+, visionOS 1+, and Swift 6. Current runtime validation has been performed on iOS 15+. See the Migration Guide for migration steps and behavior changes.
Use persistent history tracking to determine what changes have occurred in the store since the enabling of persistent history tracking. — Apple Documentation
When you enable Persistent History Tracking, Core Data creates transactions for all changes across:
- Your main app
- App extensions (widgets, share extensions, etc.)
- Background contexts
- CloudKit sync (if enabled)
PersistentHistoryTrackingKit automates the process of:
- 📥 Fetching new transactions from other contexts
- 🔄 Merging them into your app's context
- 🧹 Cleaning up old transactions
- 🎣 Triggering custom hooks for monitoring or custom merge logic
Want to learn more?
- 📖 Using Persistent History Tracking in CoreData - Comprehensive guide covering the fundamentals, concepts, and implementation patterns
- Minimum Requirements: iOS 13+, macOS 10.15+, macCatalyst 13+, tvOS 13+, watchOS 6+, visionOS 1+, Swift 6.0+
- Features: Actor-based architecture, Hook system, full Swift 6 concurrency
- Runtime Validation: Currently tested on iOS 15+ with current Xcode toolchains
- Recommended for: New projects adopting Swift 6
- Minimum Requirements: iOS 13+, macOS 10.15+, Swift 5.5+
- Features: Proven stability, lower system requirements
- Recommended for: Projects that prefer a pre-Swift-6 toolchain or the battle-tested V1 API
Use V1 if:
- You need to stay on a pre-Swift-6 toolchain
- You're not ready to migrate to Swift 6
- You prefer the battle-tested V1 API
📦 Install V1:
dependencies: [
.package(url: "https://github.com/fatbobman/PersistentHistoryTrackingKit.git", from: "1.0.0")
]Or use the version-1 branch: V1 Documentation
Moving an existing V1 app to V2? Read the Migration Guide.
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/fatbobman/PersistentHistoryTrackingKit.git", from: "2.0.0")
]import CoreData
import PersistentHistoryTrackingKit
// 1. Enable persistent history tracking in your Core Data stack
let container = NSPersistentContainer(name: "MyApp")
let description = container.persistentStoreDescriptions.first!
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.loadPersistentStores { _, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}
// 2. Set transaction authors
container.viewContext.transactionAuthor = "MainApp"
// 3. Initialize PersistentHistoryTrackingKit
let kit = PersistentHistoryTrackingKit(
container: container,
contexts: [container.viewContext],
currentAuthor: "MainApp",
allAuthors: ["MainApp", "WidgetExtension", "ShareExtension"],
userDefaults: .standard,
cleanStrategy: .byDuration(seconds: 60 * 60 * 24 * 7), // 7 days
logLevel: 1
)
// Kit starts automatically by defaultThat's it! The kit will now automatically:
- Detect remote changes
- Merge transactions from other authors
- Clean up old history
- Keep your contexts in sync
Each part of your app should have a unique author name:
// Main app
container.viewContext.transactionAuthor = "MainApp"
// Widget extension
widgetContext.transactionAuthor = "WidgetExtension"
// Background batch operations
batchContext.transactionAuthor = "BatchProcessor"Then configure the kit with all authors:
allAuthors: ["MainApp", "WidgetExtension", "BatchProcessor"]Important: Transaction cleanup is optional and low-overhead. Old transactions don't impact performance significantly. There's no need for aggressive cleanup - choose a relaxed interval that works for your app.
// Option 1: Time-based cleanup (recommended)
// Clean up at most once per time interval
cleanStrategy: .byDuration(seconds: 60 * 60 * 24 * 7) // 7 days
// Option 2: Notification-based cleanup
// Clean up after N notifications (less common)
cleanStrategy: .byNotification(times: 10)
// Option 3: No automatic cleanup (manual control)
cleanStrategy: .noneRecommendations:
- Most apps: Use
.byDuration(seconds: 60 * 60 * 24 * 7)(7 days) - provides a good balance - CloudKit users: Must use
.byDuration(seconds: 60 * 60 * 24 * 7)or longer to avoidNSPersistentHistoryTokenExpiredError - Frequent transactions: Consider
.byDuration(seconds: 60 * 60 * 24 * 3)(3 days) - Manual control: Use
.noneand clean on specific events (app background, etc.)
Automatic cleanup is conservative: the kit cleans only after every non-batch author has recorded
its merge timestamp in the shared UserDefaults store. If one required author has not merged yet,
automatic cleanup is skipped.
CloudKit relies on persistent history internally. If history is cleaned up too aggressively, CloudKit may lose its tracking tokens, causing NSPersistentHistoryTokenExpiredError (error code 134301), which can lead to local database purges and forced re-sync from iCloud.
Always use time-based cleanup with sufficient duration (7+ days) when using CloudKit:
let kit = PersistentHistoryTrackingKit(
container: container,
currentAuthor: "MainApp",
allAuthors: ["MainApp", "WidgetExtension"],
cleanStrategy: .byDuration(seconds: 60 * 60 * 24 * 7), // 7 days minimum for CloudKit
userDefaults: userDefaults
)Note: By default, the kit does not clean up transactions generated by NSPersistentCloudKitContainer (CloudKit mirroring), avoiding interference with CloudKit's internal synchronization.
For maximum flexibility, you can control when cleanup happens:
let kit = PersistentHistoryTrackingKit(
// ... other parameters
cleanStrategy: .none, // Disable automatic cleanup
autoStart: false
)
// Build a manual cleaner
let cleaner = kit.cleanerBuilder()
// Clean up at your preferred timing
// For example: when app enters background, during low usage, etc.
Task {
await cleaner.clean()
}
// Start the kit when ready
kit.start()V2 introduces a powerful Hook System for monitoring changes and customizing merge behavior.
Monitor specific entity operations without modifying data:
// Monitor Person insertions
let hookId = await kit.registerObserver(
entityName: "Person",
operation: .insert
) { contexts in
for context in contexts {
print("New person created: \(context.objectIDURL)")
// Send analytics
await Analytics.track(event: "person_created", properties: [
"timestamp": context.timestamp,
"author": context.author
])
}
}
// Remove specific hook later
await kit.removeObserver(id: hookId)
// Or remove all hooks for an entity+operation
await kit.removeObserver(entityName: "Person", operation: .insert)Context batching: The callback receives [HookContext] that groups changes per transaction + entity + operation. Insert multiple Person objects in one transaction → one callback with an array of contexts.
Use cases: Logging, analytics, notifications, cache invalidation
Implement custom merge behavior with full access to Core Data:
// Custom conflict resolution
await kit.registerMergeHook { input in
for transaction in input.transactions {
for context in input.contexts {
await context.perform {
// Custom merge logic here
// You have full access to NSManagedObjectContext
}
}
}
// Return .goOn to continue to next hook
// Return .finish to skip remaining hooks and default merge
return .goOn
}Use cases: Conflict resolution, deduplication, validation, custom merge strategies
Deduplication:
await kit.registerMergeHook { input in
for context in input.contexts {
await context.perform {
for transaction in input.transactions {
guard let changes = transaction.changes else { continue }
for change in changes where change.changeType == .insert {
guard let object = try? context.existingObject(with: change.changedObjectID),
let uniqueID = object.value(forKey: "uniqueID") as? String else {
continue
}
// Find duplicates and remove
// ... deduplication logic
}
}
try? context.save()
}
}
return .goOn
}📚 Complete Hook Documentation: Docs/HookMechanism.md
| Parameter | Type | Description | Default |
|---|---|---|---|
container |
NSPersistentContainer |
Your Core Data container | Required |
contexts |
[NSManagedObjectContext]? |
Contexts to merge into | [container.viewContext] |
currentAuthor |
String |
Current app's author name | Required |
allAuthors |
[String] |
All author names to track | Required |
includingCloudKitMirroring |
Bool |
Include CloudKit transactions | false |
batchAuthors |
[String] |
Authors that only write, never merge | [] |
userDefaults |
UserDefaults |
Storage for timestamps | Required |
cleanStrategy |
TransactionCleanStrategy |
Cleanup strategy | .none |
maximumDuration |
TimeInterval |
Reserved for future cleanup readiness policies | 7 days |
uniqueString |
String |
UserDefaults key prefix | Auto-generated |
logger |
PersistentHistoryTrackingKitLoggerProtocol? |
Custom logger | DefaultLogger |
logLevel |
Int |
Log verbosity (0-2) | 1 |
autoStart |
Bool |
Start automatically | true |
// Register observer hook (returns UUID for removal)
func registerObserver(
entityName: String,
operation: HookOperation,
callback: @escaping HookCallback
) async -> UUID
// Remove specific hook by UUID
func removeObserver(id: UUID) async -> Bool
// Remove all hooks for entity+operation
func removeObserver(entityName: String, operation: HookOperation) async
// Remove all observer hooks
func removeAllObservers() async// Register merge hook (returns UUID)
func registerMergeHook(
before hookId: UUID? = nil,
callback: @escaping MergeHookCallback
) async -> UUID
// Remove specific merge hook
func removeMergeHook(id: UUID) async -> Bool
// Remove all merge hooks
func removeAllMergeHooks() async// Start/stop the kit
func start()
func stop()
// Build a manual cleaner
func cleanerBuilder() -> ManualCleanerActorFor sharing data across app and extensions:
let appGroupDefaults = UserDefaults(suiteName: "group.com.yourapp")!
let kit = PersistentHistoryTrackingKit(
container: container,
currentAuthor: "MainApp",
allAuthors: ["MainApp", "WidgetExtension"],
userDefaults: appGroupDefaults, // Use shared UserDefaults
cleanStrategy: .byDuration(seconds: 60 * 60 * 24 * 7)
)Integrate with your logging system:
struct MyLogger: PersistentHistoryTrackingKitLoggerProtocol {
func log(type: PersistentHistoryTrackingKitLogType, message: String) {
switch type {
case .debug:
Logger.debug(message)
case .info:
Logger.info(message)
case .notice:
Logger.notice(message)
case .error:
Logger.error(message)
case .fault:
Logger.fault(message)
}
}
}
let kit = PersistentHistoryTrackingKit(
// ... other parameters
logger: MyLogger(),
logLevel: 2 // 0: off, 1: important, 2: detailed
)Observer Hooks execute in registration order:
// These execute sequentially: Hook 1 → Hook 2 → Hook 3
let hook1 = await kit.registerObserver(entityName: "Person", operation: .insert) { _ in
print("Hook 1")
}
let hook2 = await kit.registerObserver(entityName: "Person", operation: .insert) { _ in
print("Hook 2")
}
let hook3 = await kit.registerObserver(entityName: "Person", operation: .insert) { _ in
print("Hook 3")
}
// Remove only Hook 2
await kit.removeObserver(id: hook2)
// Now only Hook 1 and Hook 3 executeMerge Hooks support pipeline insertion:
let hookA = await kit.registerMergeHook { _ in
print("Hook A")
return .goOn
}
// Insert before hookA
let hookB = await kit.registerMergeHook(before: hookA) { _ in
print("Hook B")
return .goOn
}
// Execution order: Hook B → Hook A- iOS 13.0+ / macOS 10.15+ / macCatalyst 13.0+ / tvOS 13.0+ / watchOS 6.0+ / visionOS 1.0+
- Swift 6.0+
- Xcode 16.0+
Runtime validation in the current toolchain environment has been performed on iOS 15+. Older declared deployment targets are compiler-checked but have not been runtime-validated here.
- Hook Mechanism Guide - Complete guide to Observer and Merge Hooks
- Migration Guide - API, behavior, and platform changes from V1 to V2
- Core Data Persistent History Tracking - Blog post on the fundamentals
Tests are validated under parallel execution. The test infrastructure serializes NSPersistentContainer creation internally to avoid Core Data store-loading crashes while preserving parallel suite execution.
Current runtime validation has been performed on iOS 15+. Although the package declares support for older OS versions, iOS 13 and iOS 14 have not been runtime-validated in the current Xcode environment.
If you are running this library on iOS 13 or iOS 14:
- These versions are declared as supported by the package, but have not yet been runtime-validated by the maintainer in the current toolchain environment.
- If you encounter an issue, please include your device model, iOS version, and reproduction steps when opening an issue.
- If the library runs correctly for you on iOS 13 or iOS 14, feedback is also welcome and helps improve confidence in older OS compatibility.
# Run all tests in parallel (recommended)
./test.shThe test script ensures:
- ✅ Full-suite parallel execution
- ✅ Core Data concurrency assertions enabled
- ✅ Reliable results
If you run tests manually, prefer the same parallel settings:
# Run the full suite in parallel
swift test --parallel
# Or run an individual suite
swift test --filter HookRegistryActorTestsTest suites include:
- Unit tests for all actors and components
- Integration tests with real Core Data stack
- Concurrency stress tests
- Memory leak detection
- Hook system tests (Observer and Merge Hooks)
Contributions are welcome! Please feel free to submit a Pull Request.
git clone https://github.com/fatbobman/PersistentHistoryTrackingKit.git
cd PersistentHistoryTrackingKit
swift build
./test.shThis library is released under the MIT license. See LICENSE for details.
Fatbobman (肘子)
- Blog: fatbobman.com
- Newsletter: Fatbobman's Swift Weekly
- Twitter: @fatbobman
Thanks to the Swift and Core Data communities for their valuable feedback and contributions.
Special thanks to contributors who helped improve V2:
- Community members who submitted PRs for undo manager handling and deduplication strategies
- Early testers of the Swift 6 migration
If you find this library helpful, consider supporting my work:
Your support helps me continue maintaining and improving open-source Swift libraries. Thank you! 🙏