Created
May 19, 2025 16:12
-
-
Save andrewtheis/56da98904be7879de41e938890e549d0 to your computer and use it in GitHub Desktop.
AsyncSwiftDataChangeObserver - A wrapper for handling external context changes via fetchHistory
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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