An open API service indexing awesome lists of open source software.

https://github.com/fatbobman/persistenthistorytrackingkit

A library for managing Core Data's Persistent History Tracking
https://github.com/fatbobman/persistenthistorytrackingkit

coredata persistent-storage

Last synced: 3 months ago
JSON representation

A library for managing Core Data's Persistent History Tracking

Awesome Lists containing this project

README

          

# Persistent History Tracking Kit 2

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

![Platform](https://img.shields.io/badge/Platform-iOS%2013%2B%20%7C%20macOS%2010.15%2B%20%7C%20macCatalyst%2013%2B%20%7C%20tvOS%2013%2B%20%7C%20watchOS%206%2B%20%7C%20visionOS%201%2B-blue)
![Swift](https://img.shields.io/badge/Swift-6.0-orange)
![License](https://img.shields.io/badge/License-MIT-green)[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fatbobman/PersistentHistoryTrackingKit)

[English](https://github.com/fatbobman/PersistentHistoryTrackingKit/blob/main/README.md) | [中文版说明](https://github.com/fatbobman/PersistentHistoryTrackingKit/blob/main/READMECN.md)

---

## 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](Docs/MigrationGuide.md) 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?**

- 📖 **[Using Persistent History Tracking in CoreData](https://fatbobman.com/en/posts/persistenthistorytracking/)** - Comprehensive guide covering the fundamentals, concepts, and implementation patterns

---

## 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**:

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

Or use the `version-1` branch: [V1 Documentation](https://github.com/fatbobman/PersistentHistoryTrackingKit/tree/version-1)

Moving an existing V1 app to V2? Read the [Migration Guide](Docs/MigrationGuide.md).

---

## Quick Start

### Installation

Add to your `Package.swift`:

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

### Basic Setup

```swift
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:

```swift
// Main app
container.viewContext.transactionAuthor = "MainApp"

// Widget extension
widgetContext.transactionAuthor = "WidgetExtension"

// Background batch operations
batchContext.transactionAuthor = "BatchProcessor"
```

Then configure the kit with all authors:

```swift
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.

```swift
// 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:

```swift
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:

```swift
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:

```swift
// 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:

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

Merge Hooks can customize the merge pipeline itself, for example by temporarily disabling `undoManager` while applying history transactions.

**📚 Complete Hook Documentation:** [Docs/HookMechanism.md](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

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

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

```swift
// 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:

```swift
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:

```swift
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**:

```swift
// 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**:

```swift
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

- **[Hook Mechanism Guide](Docs/HookMechanism.md)** - Complete guide to Observer and Merge Hooks
- **[Migration Guide](Docs/MigrationGuide.md)** - API, behavior, and platform changes from V1 to V2
- **[Core Data Persistent History Tracking](https://fatbobman.com/en/posts/persistenthistorytracking/)** - Blog post on the fundamentals

---

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

```bash
# 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:

```bash
# 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

```bash
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](LICENSE) for details.

---

## Author

**Fatbobman (肘子)**

- Blog: [fatbobman.com](https://fatbobman.com)
- Newsletter: [Fatbobman's Swift Weekly](https://weekly.fatbobman.com)
- Twitter: [@fatbobman](https://twitter.com/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](https://buymeacoffee.com/fatbobman)**

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