Skip to content

Instantly share code, notes, and snippets.

@andrewtheis
Created May 19, 2025 16:12
Show Gist options
  • Save andrewtheis/56da98904be7879de41e938890e549d0 to your computer and use it in GitHub Desktop.
Save andrewtheis/56da98904be7879de41e938890e549d0 to your computer and use it in GitHub Desktop.
AsyncSwiftDataChangeObserver - A wrapper for handling external context changes via fetchHistory
//
// Copyright © 2024-2025 Hidden Spectrum, LLC. All rights reserved.
//
import Foundation
import SwiftData
public protocol AsyncSwiftDataChangeObserver: ModelActor {
var observerInitDate: Date { get }
var lastHistoryFetchToken: DefaultHistoryToken? { get set }
var observeModelTypes: [any PersistentModel.Type] { get }
func handleInsert<Model: PersistentModel>(of model: Model)
func handleUpdate<Model: PersistentModel>(of model: Model)
func handleDelete<HD: HistoryDelete>(of deletion: HD)
}
public extension AsyncSwiftDataChangeObserver {
func handleInsert<Model: PersistentModel>(of model: Model) {}
func handleUpdate<Model: PersistentModel>(of model: Model) {}
func handleDelete<HD: HistoryDelete>(of deletion: HD) {}
}
public extension AsyncSwiftDataChangeObserver {
func observeExternalContextChanges() {
// Handles local changes
Task {
for await didSave in NotificationCenter.default.notifications(named: ModelContext.didSave) {
if contextSaveNotificationHasChanges(didSave) {
handleStoreUpdates()
}
}
}
}
private func contextSaveNotificationHasChanges(_ notification: Notification) -> Bool {
guard let userInfo = notification.userInfo else {
return false
}
let keys: [ModelContext.NotificationKey] = [.deletedIdentifiers, .insertedIdentifiers, .updatedIdentifiers]
for key in keys {
guard let identifiers = userInfo[key.rawValue] as? [PersistentIdentifier] else {
continue
}
if !identifiers.isEmpty {
return true
}
}
return false
}
nonisolated func checkForUpdates() {
Task {
await handleStoreUpdates()
}
}
private func handleStoreUpdates() {
let transactions = fetchHistoryTransactions()
processTransactions(transactions)
}
private func fetchHistoryTransactions() -> [DefaultHistoryTransaction] {
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
if let lastToken = lastHistoryFetchToken {
descriptor.predicate = #Predicate { $0.token > lastToken }
} else {
let observerInitDate = self.observerInitDate
descriptor.predicate = #Predicate { $0.timestamp >= observerInitDate }
}
do {
return try modelContext.fetchHistory(descriptor)
} catch {
print("Failed to fetch history: \(error)")
return []
}
}
private func processTransactions(_ transactions: [DefaultHistoryTransaction]) {
for transaction in transactions {
for change in transaction.changes {
handleChange(change)
}
}
if let lastTransaction = transactions.last {
lastHistoryFetchToken = lastTransaction.token
}
}
private func handleChange(_ change: HistoryChange) {
switch change {
case .insert(let historyInsert):
guard let modelType = shouldProcessInsert(historyInsert) else {
return
}
guard let model = fetchModel(type: modelType, id: historyInsert.changedPersistentIdentifier) else {
return
}
handleInsert(of: model)
case .update(let historyUpdate):
guard let modelType = shouldProcessUpdate(historyUpdate) else {
return
}
guard let model = fetchModel(type: modelType, id: historyUpdate.changedPersistentIdentifier) else {
return
}
handleUpdate(of: model)
case .delete(let historyDelete):
guard shouldProcessDelete(historyDelete) else {
return
}
handleDelete(of: historyDelete)
default:
break
}
}
private func shouldProcessInsert<HI: HistoryInsert>(_ historyInsert: HI) -> HI.Model.Type? {
guard observeModelTypes.contains(where: { $0 == HI.Model.self }) else {
return nil
}
return HI.Model.self
}
private func shouldProcessUpdate<HU: HistoryUpdate>(_ historyUpdate: HU) -> HU.Model.Type? {
guard observeModelTypes.contains(where: { $0 == HU.Model.self }) else {
return nil
}
return HU.Model.self
}
private func shouldProcessDelete<HD: HistoryDelete>(_ historyDelete: HD) -> Bool {
guard observeModelTypes.contains(where: { $0 == HD.Model.self }) else {
return false
}
return true
}
private func concreteHistoryType<M: PersistentModel, HU: HistoryUpdate>(_ historyUpdate: any HistoryUpdate, modelType: M.Type) -> HU? where M == HU.Model {
guard let typedUpdate = historyUpdate as? HU else {
return nil
}
return typedUpdate
}
private func fetchModel<Model: PersistentModel>(type: Model.Type, id persistentID: PersistentIdentifier) -> Model? {
var fetchDescriptor = FetchDescriptor<Model>()
fetchDescriptor.fetchLimit = 1
fetchDescriptor.predicate = #Predicate {
$0.persistentModelID == persistentID
}
return try? modelContext.fetch(fetchDescriptor).first
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment