Skip to content

fatbobman/PersistentHistoryTrackingKit

Repository files navigation

Persistent History Tracking Kit 2

Swift 6 readyActor-basedFully concurrentType-safe

A modern, production-ready library for handling Core Data's Persistent History Tracking with full Swift 6 concurrency support.

Platform Swift LicenseAsk DeepWiki

English | 中文版说明


What's New in V2 🎉

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 HookRegistryActor and TransactionProcessorActor
  • 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.


What is Persistent History Tracking?

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:

  1. 📥 Fetching new transactions from other contexts
  2. 🔄 Merging them into your app's context
  3. 🧹 Cleaning up old transactions
  4. 🎣 Triggering custom hooks for monitoring or custom merge logic

Want to learn more?


Version Availability

V2 (Current Branch)

  • 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

V1 (Stable)

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


Quick Start

Installation

Add to your Package.swift:

dependencies: [
    .package(url: "https://github.com/fatbobman/PersistentHistoryTrackingKit.git", from: "2.0.0")
]

Basic Setup

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 default

That's it! The kit will now automatically:

  • Detect remote changes
  • Merge transactions from other authors
  • Clean up old history
  • Keep your contexts in sync

Core Concepts

Authors

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"]

Cleanup Strategies

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: .none

Recommendations:

  • 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 avoid NSPersistentHistoryTokenExpiredError
  • Frequent transactions: Consider .byDuration(seconds: 60 * 60 * 24 * 3) (3 days)
  • Manual control: Use .none and 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.

⚠️ Important for CloudKit Users:

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.

Manual Cleanup

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()

Hook System 🎣

V2 introduces a powerful Hook System for monitoring changes and customizing merge behavior.

Observer Hooks (Read-Only Monitoring)

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

Merge Hooks (Custom Merge Logic)

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

Real-World Examples

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


API Reference

Initialization Parameters

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

Observer Hook Methods

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

Merge Hook Methods

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

Control Methods

// Start/stop the kit
func start()
func stop()

// Build a manual cleaner
func cleanerBuilder() -> ManualCleanerActor

Advanced Usage

App Groups

For 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)
)

Custom Logger

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
)

Multiple Hooks with Execution Order

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 execute

Merge 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

Requirements

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


Documentation


Testing

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.

iOS 13-14 Users

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.

Recommended: Use the test script

# Run all tests in parallel (recommended)
./test.sh

The test script ensures:

  • ✅ Full-suite parallel execution
  • ✅ Core Data concurrency assertions enabled
  • ✅ Reliable results

Alternative: Manual testing (caution required)

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 HookRegistryActorTests

Test 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)

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Development Setup

git clone https://github.com/fatbobman/PersistentHistoryTrackingKit.git
cd PersistentHistoryTrackingKit
swift build
./test.sh

License

This library is released under the MIT license. See LICENSE for details.


Author

Fatbobman (肘子)


Acknowledgments

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

Sponsor

If you find this library helpful, consider supporting my work:

Buy Me A Coffee

☕ Buy Me a Coffee

Your support helps me continue maintaining and improving open-source Swift libraries. Thank you! 🙏

About

A library for managing Core Data's Persistent History Tracking

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors