Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Synchronize creation of Events and Authors #632

Merged
merged 10 commits into from
Nov 17, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed incorrect ellipsis applied to long notes.
- Changed note rendering to retain more newlines.
- Show reposts in stories.
- Fixed a bug where notes, reposts, and author profiles could fail to load.

## [0.1 (86)] - 2023-10-25Z

Expand Down
30 changes: 25 additions & 5 deletions Nos/Models/Author+CoreDataClass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import CoreData
import Dependencies
import Logger

enum AuthorError: Error {
case coreData
}

@objc(Author)
public class Author: NosManagedObject {

Expand Down Expand Up @@ -100,13 +104,29 @@ public class Author: NosManagedObject {

@discardableResult
class func findOrCreate(by pubKey: HexadecimalString, context: NSManagedObjectContext) throws -> Author {
@Dependency(\.persistenceController) var persistenceController
@Dependency(\.crashReporting) var crashReporting

if let author = try? Author.find(by: pubKey, context: context) {
return author
} else {
let author = Author(context: context)
author.hexadecimalPublicKey = pubKey
author.muted = false
return author
/// Always create authors in the creationContext first to make sure we never end up with two identical
/// Authors in different contexts with the same objectID, because this messes up SwiftUI's observation
/// of changes.
let creationContext = persistenceController.creationContext
let objectID = try creationContext.performAndWait {
let author = Author(context: creationContext)
author.hexadecimalPublicKey = pubKey
author.muted = false
try creationContext.save()
return author.objectID
}
guard let fetchedAuthor = context.object(with: objectID) as? Author else {
let error = AuthorError.coreData
crashReporting.report(error)
throw error
}
return fetchedAuthor
}
}

Expand Down Expand Up @@ -213,7 +233,7 @@ public class Author: NosManagedObject {
let fetchRequest = NSFetchRequest<Author>(entityName: "Author")
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Author.lastUpdatedContactList, ascending: false)]
fetchRequest.predicate = NSPredicate(
format: "hexadecimalPublicKey IN %@.follows.destination.hexadecimalPublicKey",
format: "ANY followers.source = %@",
author
)
return fetchRequest
Expand Down
69 changes: 54 additions & 15 deletions Nos/Models/Event+CoreDataClass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum EventError: Error {
case invalidETag([String])
case invalidSignature(Event)
case expiredEvent
case coreData

var description: String? {
switch self {
Expand All @@ -37,7 +38,7 @@ enum EventError: Error {
case .expiredEvent:
return "This event has expired"
default:
return ""
return "An unkown error occurred."
}
}
}
Expand Down Expand Up @@ -75,6 +76,7 @@ extension FetchedResults where Element == Event {
public class Event: NosManagedObject {

@Dependency(\.currentUser) private var currentUser
@Dependency(\.persistenceController) private var persistenceController

static var replyNoteReferences = "kind = 1 AND ANY eventReferences.referencedEvent.identifier == %@ " +
"AND author.muted = false"
Expand Down Expand Up @@ -466,7 +468,7 @@ public class Event: NosManagedObject {

// MARK: - Creating

class func createIfNecessary(
func createIfNecessary(
jsonEvent: JSONEvent,
relay: Relay?,
context: NSManagedObjectContext
Expand All @@ -481,28 +483,65 @@ public class Event: NosManagedObject {
try existingEvent.hydrate(from: jsonEvent, relay: relay, in: context)
}
return existingEvent
} else {
@Dependency(\.crashReporting) var crashReporting
@Dependency(\.persistenceController) var persistenceController

/// Always create events in the creationContext first to make sure we never end up with two identical
/// Events in different contexts with the same objectID, because this messes up SwiftUI's observation
/// of changes.
let creationContext = persistenceController.creationContext
let objectID = try creationContext.performAndWait {
let event = Event(context: creationContext)
event.identifier = jsonEvent.id
try creationContext.save()
return event.objectID
}
guard let fetchedEvent = context.object(with: objectID) as? Event else {
let error = EventError.coreData
crashReporting.report(error)
throw error
}

fetchedEvent.receivedAt = .now
try fetchedEvent.hydrate(from: jsonEvent, relay: relay, in: context)
return fetchedEvent
}

return try Event(context: context, jsonEvent: jsonEvent, relay: relay)
}

class func findOrCreateStubBy(id: String, context: NSManagedObjectContext) throws -> Event {
/// Fetches the event with the given ID out of the database, and otherwise creates a stubbed Event.
/// A stubbed event only has an `identifier` - we know an event with this identifier exists but we don't
/// have its content or tags yet.
///
/// - Parameters:
/// - id: The hexadecimal Nostr ID of the event.
/// - Returns: The Event model with the given ID.
class func findOrCreateStubBy(id: HexadecimalString, context: NSManagedObjectContext) throws -> Event {
if let existingEvent = try context.fetch(Event.event(by: id)).first {
return existingEvent
} else {
let event = Event(context: context)
event.identifier = id
return event
@Dependency(\.crashReporting) var crashReporting
@Dependency(\.persistenceController) var persistenceController

/// Always create events in the creationContext first to make sure we never end up with two identical
/// Events in different contexts with the same objectID, because this messes up SwiftUI's observation
/// of changes.
let creationContext = persistenceController.creationContext
let objectID = try creationContext.performAndWait {
let event = Event(context: creationContext)
event.identifier = id
try creationContext.save()
return event.objectID
}
guard let fetchedEvent = context.object(with: objectID) as? Event else {
let error = EventError.coreData
crashReporting.report(error)
throw error
}
return fetchedEvent
}
}

convenience init(context: NSManagedObjectContext, jsonEvent: JSONEvent, relay: Relay?) throws {
self.init(context: context)
identifier = jsonEvent.id
receivedAt = .now
try hydrate(from: jsonEvent, relay: relay, in: context)
}

func deleteEvents(identifiers: [String], context: NSManagedObjectContext) async {
print("Deleting: \(identifiers)")
let deleteRequest = Event.deletePostsRequest(for: identifiers)
Expand Down
53 changes: 45 additions & 8 deletions Nos/Models/Persistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,34 @@
container.viewContext
}

lazy var backgroundViewContext = {
/// A context to synchronize creation of Events and Authors so we don't end up with duplicates.
lazy var creationContext = {
newBackgroundContext()
}()

var container: NSPersistentContainer
/// A context for parsing Nostr events from relays.
lazy var parseContext = {
self.newBackgroundContext()
}()

/// A context for Views to do expensive queries that we want to keep off the viewContext.
lazy var backgroundViewContext = {
self.newBackgroundContext()
}()

private(set) var container: NSPersistentContainer
private var model: NSManagedObjectModel
private var inMemory: Bool

init(inMemory: Bool = false) {
init(containerName: String = "Nos", inMemory: Bool = false) {
self.inMemory = inMemory
let modelURL = Bundle.current.url(forResource: "Nos", withExtension: "momd")!
container = NSPersistentContainer(
name: "Nos",
managedObjectModel: NSManagedObjectModel(contentsOf: modelURL)!
)
model = NSManagedObjectModel(contentsOf: modelURL)!
container = NSPersistentContainer(name: containerName, managedObjectModel: model)
setUp()
}

func setUp() {
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
Expand All @@ -56,6 +72,26 @@
container.viewContext.mergePolicy = NSMergePolicy(merge: mergeType)
}

#if DEBUG
func resetForTesting() {
container = NSPersistentContainer(name: "Nos", managedObjectModel: model)
if !inMemory {
container.loadPersistentStores(completionHandler: { (storeDescription, error) in

Check failure on line 79 in Nos/Models/Persistence.swift

View workflow job for this annotation

GitHub Actions / swift_lint

Unused parameter in a closure should be replaced with _ (unused_closure_parameter)
guard let storeURL = storeDescription.url else {
Log.error("Could not get store URL")
return
}
Self.clearCoreData(store: storeURL, in: self.container)
})
}
setUp()
viewContext.reset()
creationContext = newBackgroundContext()
backgroundViewContext = newBackgroundContext()
parseContext = newBackgroundContext()
}
#endif

private func loadPersistentStores(from container: NSPersistentContainer) {
container.loadPersistentStores(completionHandler: { (storeDescription, error) in

Expand Down Expand Up @@ -156,6 +192,7 @@
/// - delete any other models that are orphaned by the previous deletions
/// - fix EventReferences whose referencedEvent was deleted by createing a stubbed Event
@MainActor func cleanupEntities() {
return
// this function was written in a hurry and probably should be refactored and tested thorougly.
guard cleanupTask == nil else {
Log.info("Core Data cleanup task already running. Aborting.")
Expand All @@ -168,7 +205,7 @@

cleanupTask = Task {
defer { self.cleanupTask = nil }
let context = backgroundViewContext
let context = parseContext
let startTime = Date.now
Log.info("Starting Core Data cleanup...")

Expand Down
6 changes: 4 additions & 2 deletions Nos/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SwiftUI
import Combine
import CoreData
import Logger
import Dependencies

// Manages the app's navigation state.
@MainActor class Router: ObservableObject {
Expand All @@ -19,6 +20,7 @@ import Logger
@Published var profilePath = NavigationPath()
@Published var sideMenuPath = NavigationPath()
@Published var selectedTab = AppDestination.home
@Dependency(\.persistenceController) private var persistenceController

var currentPath: Binding<NavigationPath> {
if sideMenuOpened {
Expand Down Expand Up @@ -101,9 +103,9 @@ extension Router {
// the hex format pubkey of the mentioned author
do {
if link.hasPrefix("@") {
push(try Author.findOrCreate(by: identifier, context: context))
push(try Author.findOrCreate(by: identifier, context: persistenceController.parseContext))
} else if link.hasPrefix("%") {
push(try Event.findOrCreateStubBy(id: identifier, context: context))
push(try Event.findOrCreateStubBy(id: identifier, context: persistenceController.parseContext))
} else if url.scheme == "http" || url.scheme == "https" {
push(url)
} else {
Expand Down
2 changes: 1 addition & 1 deletion Nos/Service/DependencyInjection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ fileprivate enum PushNotificationServiceKey: DependencyKey {

fileprivate enum PersistenceControllerKey: DependencyKey {
static let liveValue = PersistenceController()
static let testValue = PersistenceController(inMemory: true)
static var testValue = PersistenceController(inMemory: true)
static let previewValue = PersistenceController(inMemory: true)
}

Expand Down
2 changes: 1 addition & 1 deletion Nos/Service/EventProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ enum EventProcessor {
in parseContext: NSManagedObjectContext,
skipVerification: Bool = false
) throws -> Event? {
if let event = try Event.createIfNecessary(jsonEvent: jsonEvent, relay: relay, context: parseContext) {
if let event = try Event().createIfNecessary(jsonEvent: jsonEvent, relay: relay, context: parseContext) {
relay.unwrap {
do {
try event.trackDelete(on: $0, context: parseContext)
Expand Down
2 changes: 1 addition & 1 deletion Nos/Service/PushNotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ import Combine
if let viewModel {
// Leave an hour of margin on the notificationcutoff to allow for events arriving slightly out of order.
notificationCutoff = viewModel.date.addingTimeInterval(-60 * 60)
await viewModel.loadContent(in: self.modelContext)
await viewModel.loadContent(in: self.persistenceController.parseContext)

do {
try await UNUserNotificationCenter.current().add(viewModel.notificationCenterRequest)
Expand Down
2 changes: 1 addition & 1 deletion Nos/Service/RelayService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ final class RelayService: ObservableObject {
self.subscriptions = RelaySubscriptionManager()
@Dependency(\.persistenceController) var persistenceController
self.backgroundContext = persistenceController.newBackgroundContext()
self.parseContext = persistenceController.newBackgroundContext()
self.parseContext = persistenceController.parseContext
parseContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump

self.eventProcessingLoop = Task(priority: .userInitiated) { [weak self] in
Expand Down
Loading