From b8a616cf017b331e8f7ef1a3f66441dec24c00e7 Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Wed, 2 Oct 2024 11:49:54 -0500 Subject: [PATCH 01/12] SwiftDataStore --- .../Repository/Store/SwiftDataStore.swift | 79 +++++++++++++++++++ Sources/SwiftRepo/Repository/StoreModel.swift | 25 +++++- 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift diff --git a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift new file mode 100644 index 0000000..ce19c50 --- /dev/null +++ b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift @@ -0,0 +1,79 @@ +// +// SwiftDataStore.swift +// SwiftRepo +// +// Created by Carter Foughty on 9/27/24. +// + +import Foundation +import Combine +import SwiftData +import Core + +@MainActor +// An implementation of `Store` that uses `SwiftData` under the hood +public class SwiftDataStore: Store where Model: PersistentModel, Model.Key: Hashable { + public typealias Key = Model.Key + public typealias Value = Model + + public var keys: [Key] { + get throws { + try modelContext.fetch(FetchDescriptor()).map { $0.id } + } + } + + /// Creates a `SwiftData` store based on the provided `ModelContainer` + /// - Parameter modelContainer: the `ModelContainer` to use when storing `Model`s + public init(modelContainer: ModelContainer) { + self.modelContext = ModelContext(modelContainer) + } + + public func set(key: Key, value: Value?) throws -> Value? { + guard let value else { + try evict(for: key) + return nil + } + + if let existingValue = try get(key: key) { + // If the store contains an existing value for this key, + // merge the two as necessary and save the resulting value. + let mergedValue = Value.merge(existing: existingValue, new: value) + try evict(for: key) + modelContext.insert(mergedValue) + } else { + modelContext.insert(value) + } + try modelContext.save() + return value + } + + public func get(key: Key) throws -> Value? { + let result = try modelContext.fetch(FetchDescriptor(predicate: Value.predicate(key: key, olderThan: nil))) + return result.first + } + + public func age(of key: Key) throws -> TimeInterval? { + let result = try modelContext.fetch(FetchDescriptor(predicate: Value.predicate(key: key, olderThan: nil))) + guard let result = result.first else { return nil } + return Date.now.timeIntervalSince(result.updatedAt) + } + + public func clear() async throws { + try modelContext.delete(model: Value.self) + try modelContext.save() + } + + // MARK: - Constants + + // MARK: - Variables + + private let modelContext: ModelContext + + // MARK: - Helpers + + private func evict(for key: Key) throws { + let predicate = Value.predicate(key: key, olderThan: nil) + try modelContext.delete(model: Value.self, where: predicate) + try modelContext.save() + } +} diff --git a/Sources/SwiftRepo/Repository/StoreModel.swift b/Sources/SwiftRepo/Repository/StoreModel.swift index 5be5032..c681baa 100644 --- a/Sources/SwiftRepo/Repository/StoreModel.swift +++ b/Sources/SwiftRepo/Repository/StoreModel.swift @@ -15,14 +15,31 @@ public protocol StoreModel { var id: Key { get } var updatedAt: Date { get set } /// A predicate that can be used to query for the `StoreModel` - static func predicate(key: Key) -> Predicate + static func predicate(key: Key, olderThan: TimeInterval?) -> Predicate + /// A merge strategy to use when a `Value` with the `Key` is already saved in a `Store`. + /// If the new `Value` should be used, simply return `new`. + static func merge(existing: Self, new: Self) -> Self +} + +public extension StoreModel { + // By default, assume a strategy of replacing the existing value. + static func merge(existing: Self, new: Self) -> Self { + new + } } public extension StoreModel where Key == UUID { - static func predicate(key: Key) -> Predicate { - #Predicate { model in - model.id == key + static func predicate(key: Key, olderThan: TimeInterval?) -> Predicate { + if let olderThan { + let olderThanDate = Date().advanced(by: -olderThan) + return #Predicate { model in + model.id == key && model.updatedAt < olderThanDate + } + } else { + return #Predicate { model in + model.id == key + } } } } From 2dc4d3f827a28abe124b8ece80ff598ed77a656f Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Wed, 2 Oct 2024 12:37:13 -0500 Subject: [PATCH 02/12] Better merge strategy --- .../Repository/Store/SwiftDataStore.swift | 14 +++-- Sources/SwiftRepo/Repository/StoreModel.swift | 11 +--- .../Tests/DefaultObservableStoreTests.swift | 2 +- Sources/Tests/SwiftDataStoreTests.swift | 60 +++++++++++++++++++ 4 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 Sources/Tests/SwiftDataStoreTests.swift diff --git a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift index ce19c50..08d9ef6 100644 --- a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift +++ b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift @@ -15,6 +15,8 @@ import Core public class SwiftDataStore: Store where Model: PersistentModel, Model.Key: Hashable { public typealias Key = Model.Key public typealias Value = Model + /// A closure that defines how new values are merged into existing values. + public typealias Merge = (_ new: Value, _ into: Value) -> Void public var keys: [Key] { get throws { @@ -24,8 +26,10 @@ public class SwiftDataStore: Store where Model: PersistentMod /// Creates a `SwiftData` store based on the provided `ModelContainer` /// - Parameter modelContainer: the `ModelContainer` to use when storing `Model`s - public init(modelContainer: ModelContainer) { + /// - Parameter merge: the optional operation to merge a new value into an existing value + public init(modelContainer: ModelContainer, merge: Merge?) { self.modelContext = ModelContext(modelContainer) + self.merge = merge } public func set(key: Key, value: Value?) throws -> Value? { @@ -34,13 +38,12 @@ public class SwiftDataStore: Store where Model: PersistentMod return nil } - if let existingValue = try get(key: key) { + if let existingValue = try get(key: key), let merge { // If the store contains an existing value for this key, // merge the two as necessary and save the resulting value. - let mergedValue = Value.merge(existing: existingValue, new: value) - try evict(for: key) - modelContext.insert(mergedValue) + merge(value, existingValue) } else { + try evict(for: key) modelContext.insert(value) } try modelContext.save() @@ -68,6 +71,7 @@ public class SwiftDataStore: Store where Model: PersistentMod // MARK: - Variables private let modelContext: ModelContext + private let merge: Merge? // MARK: - Helpers diff --git a/Sources/SwiftRepo/Repository/StoreModel.swift b/Sources/SwiftRepo/Repository/StoreModel.swift index c681baa..9a08722 100644 --- a/Sources/SwiftRepo/Repository/StoreModel.swift +++ b/Sources/SwiftRepo/Repository/StoreModel.swift @@ -14,18 +14,9 @@ public protocol StoreModel { var id: Key { get } var updatedAt: Date { get set } + /// A predicate that can be used to query for the `StoreModel` static func predicate(key: Key, olderThan: TimeInterval?) -> Predicate - /// A merge strategy to use when a `Value` with the `Key` is already saved in a `Store`. - /// If the new `Value` should be used, simply return `new`. - static func merge(existing: Self, new: Self) -> Self -} - -public extension StoreModel { - // By default, assume a strategy of replacing the existing value. - static func merge(existing: Self, new: Self) -> Self { - new - } } public extension StoreModel where Key == UUID { diff --git a/Sources/Tests/DefaultObservableStoreTests.swift b/Sources/Tests/DefaultObservableStoreTests.swift index 389dee6..7bcc55e 100644 --- a/Sources/Tests/DefaultObservableStoreTests.swift +++ b/Sources/Tests/DefaultObservableStoreTests.swift @@ -160,7 +160,7 @@ class DefaultObservableStoreTests: XCTestCase { try await store.set(key: key1A, value: key1A) try await store.set(key: key1B, value: key1B) try await store.set(key: key2, value: key2) - let keys = await store.keys(for: publishKey1) + let keys = try await store.keys(for: publishKey1) XCTAssertEqual(keys.sorted(), [key1A, key1B].sorted()) } diff --git a/Sources/Tests/SwiftDataStoreTests.swift b/Sources/Tests/SwiftDataStoreTests.swift new file mode 100644 index 0000000..33d022c --- /dev/null +++ b/Sources/Tests/SwiftDataStoreTests.swift @@ -0,0 +1,60 @@ +// +// SwiftDataStoreTests.swift +// SwiftRepo +// +// Created by Carter Foughty on 10/2/24. +// + +import XCTest +import SwiftData +@testable import SwiftRepo + +@MainActor +class SwiftDataStoreTests: XCTestCase { + + // MARK: - Tests + + func test_merge() async throws { + let store = makeStore() + let uuid = UUID() + let model = TestStoreModel(id: uuid) + let _ = try store.set(key: model.id, value: model) + let stored = try store.get(key: model.id) + XCTAssertTrue(stored?.merged == false) + + let model2 = TestStoreModel(id: uuid) + let _ = try store.set(key: model.id, value: model2) + XCTAssertTrue(stored?.merged == true && stored?.updatedAt == model2.updatedAt) + } + + // MARK: - Constants + + @Model + final class TestStoreModel: StoreModel, Equatable { + var id: UUID + var merged = false + var updatedAt = Date() + + public init(id: UUID, updatedAt: Date = Date()) { + self.id = id + self.updatedAt = updatedAt + } + } + + // MARK: - Variables + + // MARK: - Lifecycle + + // MARK: - Helpers + + private func makeStore() -> SwiftDataStore { + let modelContainer = try! ModelContainer( + for: TestStoreModel.self, + configurations: .init("TestStore", isStoredInMemoryOnly: true) + ) + return SwiftDataStore(modelContainer: modelContainer) { new, into in + into.merged = true + into.updatedAt = new.updatedAt + } + } +} From 6719ae26b1af3e29f87ac5116f54402b8791da55 Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Wed, 2 Oct 2024 19:41:39 -0500 Subject: [PATCH 03/12] Fix timestamps --- .../Repository/Store/SwiftDataStore.swift | 18 ++-- .../Repository/Store/TimestampStore.swift | 94 +++++++++++++++++++ Sources/SwiftRepo/Repository/StoreModel.swift | 15 +-- Sources/Test/TestError.swift | 1 - Sources/Tests/SwiftDataStoreTests.swift | 18 ++++ 5 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 Sources/SwiftRepo/Repository/Store/TimestampStore.swift diff --git a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift index 08d9ef6..5f1b1ae 100644 --- a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift +++ b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift @@ -12,7 +12,7 @@ import Core @MainActor // An implementation of `Store` that uses `SwiftData` under the hood -public class SwiftDataStore: Store where Model: PersistentModel, Model.Key: Hashable { +public class SwiftDataStore: Store where Model: PersistentModel, Model.Key: Hashable & Codable { public typealias Key = Model.Key public typealias Value = Model /// A closure that defines how new values are merged into existing values. @@ -30,6 +30,7 @@ public class SwiftDataStore: Store where Model: PersistentMod public init(modelContainer: ModelContainer, merge: Merge?) { self.modelContext = ModelContext(modelContainer) self.merge = merge + self.timestampStore = TimestampStore(url: modelContainer.configurations.first?.url, modelType: Model.self) } public func set(key: Key, value: Value?) throws -> Value? { @@ -47,18 +48,18 @@ public class SwiftDataStore: Store where Model: PersistentMod modelContext.insert(value) } try modelContext.save() + // Update the timestamp store when values are updated + try timestampStore.set(key: key, value: Date()) return value } public func get(key: Key) throws -> Value? { - let result = try modelContext.fetch(FetchDescriptor(predicate: Value.predicate(key: key, olderThan: nil))) - return result.first + return try modelContext.fetch(FetchDescriptor(predicate: Value.predicate(key: key))).first } public func age(of key: Key) throws -> TimeInterval? { - let result = try modelContext.fetch(FetchDescriptor(predicate: Value.predicate(key: key, olderThan: nil))) - guard let result = result.first else { return nil } - return Date.now.timeIntervalSince(result.updatedAt) + guard let updatedAt = try timestampStore.get(key: key) else { return nil } + return Date.now.timeIntervalSince(updatedAt) } public func clear() async throws { @@ -72,12 +73,15 @@ public class SwiftDataStore: Store where Model: PersistentMod private let modelContext: ModelContext private let merge: Merge? + private let timestampStore: TimestampStore // MARK: - Helpers private func evict(for key: Key) throws { - let predicate = Value.predicate(key: key, olderThan: nil) + let predicate = Value.predicate(key: key) try modelContext.delete(model: Value.self, where: predicate) try modelContext.save() + // Clear the timestamp when the value is cleared + try timestampStore.set(key: key, value: nil) } } diff --git a/Sources/SwiftRepo/Repository/Store/TimestampStore.swift b/Sources/SwiftRepo/Repository/Store/TimestampStore.swift new file mode 100644 index 0000000..79539b0 --- /dev/null +++ b/Sources/SwiftRepo/Repository/Store/TimestampStore.swift @@ -0,0 +1,94 @@ +// +// TimestampStore.swift +// SwiftRepo +// +// Created by Carter Foughty on 10/2/24. +// + +import Foundation +import SwiftData + +/// A `Store` implementation using `SwiftData` which can be used specifically to +/// track timestamps related to models persisted using the `SwiftDataStore`. +class TimestampStore: Store { + + var keys: [Key] { + get throws { + try modelContext.fetch(FetchDescriptor()).map { $0.key } + } + } + + init(url: URL?, modelType: T.Type) { + let storeName: String = "TimestampStore-\(String(describing: modelType))" + if let url { + let modelContainer = try! ModelContainer( + for: Timestamp.self, + configurations: .init(storeName, url: url) + ) + modelContext = ModelContext(modelContainer) + } else { + let modelContainer = try! ModelContainer( + for: Timestamp.self, + configurations: .init(storeName) + ) + modelContext = ModelContext(modelContainer) + } + } + + func get(key: Key) throws -> Date? { + return try modelContext.fetch(FetchDescriptor(predicate: Timestamp.predicate(forKey: key))).first?.timestamp + } + + @discardableResult + func set(key: Key, value: Value?) throws -> Value? { + if let value { + modelContext.insert(Timestamp(key: key, timestamp: value)) + try modelContext.save() + } else { + try evict(for: key) + } + return value + } + + func age(of key: Key) throws -> TimeInterval? { + let result = try modelContext.fetch(FetchDescriptor(predicate: Timestamp.predicate(forKey: key))) + guard let result = result.first else { return nil } + return Date.now.timeIntervalSince(result.timestamp) + } + + func clear() async throws { + try modelContext.delete(model: Timestamp.self) + try modelContext.save() + } + + // MARK: - Constants + + @Model + class Timestamp { + #Index([\.key]) + + @Attribute(.unique) + var key: Key + var timestamp: Date + + init(key: Key, timestamp: Date) { + self.key = key + self.timestamp = timestamp + } + + static func predicate(forKey key: Key) -> Predicate { + #Predicate { $0.key == key } + } + } + + // MARK: - Variables + + private let modelContext: ModelContext + + // MARK: - Helpers + + private func evict(for key: Key) throws { + try modelContext.delete(model: Timestamp.self, where: Timestamp.predicate(forKey: key)) + try modelContext.save() + } +} diff --git a/Sources/SwiftRepo/Repository/StoreModel.swift b/Sources/SwiftRepo/Repository/StoreModel.swift index c2923a0..af5729c 100644 --- a/Sources/SwiftRepo/Repository/StoreModel.swift +++ b/Sources/SwiftRepo/Repository/StoreModel.swift @@ -15,21 +15,14 @@ public protocol StoreModel { /// The identifier of the model var id: Key { get } /// A predicate that can be used to query for the `StoreModel` - static func predicate(key: Key, olderThan: TimeInterval?) -> Predicate + static func predicate(key: Key) -> Predicate } public extension StoreModel where Key == UUID { - static func predicate(key: Key, olderThan: TimeInterval?) -> Predicate { - if let olderThan { - let olderThanDate = Date().advanced(by: -olderThan) - return #Predicate { model in - model.id == key && model.updatedAt < olderThanDate - } - } else { - return #Predicate { model in - model.id == key - } + static func predicate(key: Key) -> Predicate { + #Predicate { model in + model.id == key } } } diff --git a/Sources/Test/TestError.swift b/Sources/Test/TestError.swift index 6f4ff03..7f69956 100644 --- a/Sources/Test/TestError.swift +++ b/Sources/Test/TestError.swift @@ -5,7 +5,6 @@ // Created by Timothy Moose on 9/22/23. // -import Foundation import Core public struct TestError: AppError, Equatable { diff --git a/Sources/Tests/SwiftDataStoreTests.swift b/Sources/Tests/SwiftDataStoreTests.swift index 33d022c..b92d79a 100644 --- a/Sources/Tests/SwiftDataStoreTests.swift +++ b/Sources/Tests/SwiftDataStoreTests.swift @@ -26,6 +26,24 @@ class SwiftDataStoreTests: XCTestCase { let _ = try store.set(key: model.id, value: model2) XCTAssertTrue(stored?.merged == true && stored?.updatedAt == model2.updatedAt) } + + func test_timestamp() async throws { + let store = makeStore() + let uuid = UUID() + let model = TestStoreModel(id: uuid) + let _ = try store.set(key: model.id, value: model) + let stored = try store.get(key: model.id) + XCTAssertTrue(stored?.merged == false) + + let updatedAt = try store.age(of: uuid) + XCTAssertNotNil(updatedAt) + + let model2 = TestStoreModel(id: uuid) + let _ = try store.set(key: model.id, value: model2) + let updatedAt2 = try store.age(of: uuid) + XCTAssertNotNil(updatedAt2) + XCTAssertNotEqual(updatedAt, updatedAt2) + } // MARK: - Constants From ee064a39056c7cbedc04ed451f4f6bd3b0aa4277 Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Thu, 3 Oct 2024 08:52:44 -0500 Subject: [PATCH 04/12] Equatable errors --- Sources/Core/Error/AppError.swift | 2 +- Sources/Core/Error/DefaultError.swift | 5 +++-- Sources/Core/Error/UIError.swift | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/Core/Error/AppError.swift b/Sources/Core/Error/AppError.swift index e0f8e1c..820cb12 100644 --- a/Sources/Core/Error/AppError.swift +++ b/Sources/Core/Error/AppError.swift @@ -4,7 +4,7 @@ import Foundation -public protocol AppError: Error { +public protocol AppError: Error, Equatable { associatedtype UIErrorType = UIError /// Optional error data to present to the user diff --git a/Sources/Core/Error/DefaultError.swift b/Sources/Core/Error/DefaultError.swift index ff5e42b..846112c 100644 --- a/Sources/Core/Error/DefaultError.swift +++ b/Sources/Core/Error/DefaultError.swift @@ -17,7 +17,7 @@ public struct DefaultError: AppError { intent: ErrorIntent = .indispensable ) { self.uiError = uiError - self.error = error + self.error = error as? NSError self.isNotable = isNotable self.isRetryable = isRetryable self.intent = intent @@ -35,7 +35,8 @@ public struct DefaultError: AppError { public var intent: ErrorIntent - public let error: Error? + // Using `NSError` allows us to be `Equatable` + public let error: NSError? public var localizedDescription: String { if let error { diff --git a/Sources/Core/Error/UIError.swift b/Sources/Core/Error/UIError.swift index 18801bd..103ad39 100644 --- a/Sources/Core/Error/UIError.swift +++ b/Sources/Core/Error/UIError.swift @@ -9,7 +9,7 @@ import SwiftUI /// /// Use `DefaultUIError` or implement your own to have more rich /// data types including images, titles, etc. -public protocol UIError: Error, Identifiable { +public protocol UIError: Error, Identifiable, Hashable { var message: String { get } var isRetryable: Bool { get } } From 524ffbac84981317fd7582cb02313b0f248c8117 Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Thu, 3 Oct 2024 11:23:45 -0500 Subject: [PATCH 05/12] Update SwiftDataStore actor --- .../Repository/Store/SwiftDataStore.swift | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift index 5f1b1ae..66ab906 100644 --- a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift +++ b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift @@ -10,7 +10,6 @@ import Combine import SwiftData import Core -@MainActor // An implementation of `Store` that uses `SwiftData` under the hood public class SwiftDataStore: Store where Model: PersistentModel, Model.Key: Hashable & Codable { public typealias Key = Model.Key @@ -18,6 +17,7 @@ public class SwiftDataStore: Store where Model: PersistentMod /// A closure that defines how new values are merged into existing values. public typealias Merge = (_ new: Value, _ into: Value) -> Void + @MainActor public var keys: [Key] { get throws { try modelContext.fetch(FetchDescriptor()).map { $0.id } @@ -28,11 +28,12 @@ public class SwiftDataStore: Store where Model: PersistentMod /// - Parameter modelContainer: the `ModelContainer` to use when storing `Model`s /// - Parameter merge: the optional operation to merge a new value into an existing value public init(modelContainer: ModelContainer, merge: Merge?) { - self.modelContext = ModelContext(modelContainer) + self.modelContainer = modelContainer self.merge = merge self.timestampStore = TimestampStore(url: modelContainer.configurations.first?.url, modelType: Model.self) } + @MainActor public func set(key: Key, value: Value?) throws -> Value? { guard let value else { try evict(for: key) @@ -53,15 +54,18 @@ public class SwiftDataStore: Store where Model: PersistentMod return value } + @MainActor public func get(key: Key) throws -> Value? { return try modelContext.fetch(FetchDescriptor(predicate: Value.predicate(key: key))).first } + @MainActor public func age(of key: Key) throws -> TimeInterval? { guard let updatedAt = try timestampStore.get(key: key) else { return nil } return Date.now.timeIntervalSince(updatedAt) } + @MainActor public func clear() async throws { try modelContext.delete(model: Value.self) try modelContext.save() @@ -71,12 +75,26 @@ public class SwiftDataStore: Store where Model: PersistentMod // MARK: - Variables - private let modelContext: ModelContext + @MainActor + private var _modelContext: ModelContext? + private let modelContainer: ModelContainer private let merge: Merge? private let timestampStore: TimestampStore + @MainActor + private var modelContext: ModelContext { + if let _modelContext { + return _modelContext + } else { + let context = ModelContext(modelContainer) + _modelContext = context + return context + } + } + // MARK: - Helpers + @MainActor private func evict(for key: Key) throws { let predicate = Value.predicate(key: key) try modelContext.delete(model: Value.self, where: predicate) From 3892761c09499521ac9d2e12400c396d2527e1aa Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Thu, 3 Oct 2024 12:24:02 -0500 Subject: [PATCH 06/12] Fix Timestamp store bug --- .../xcschemes/xcschememanagement.plist | 8 +++ .../Repository/Store/SwiftDataStore.swift | 2 +- .../Repository/Store/TimestampStore.swift | 54 ++++++++++--------- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/.swiftpm/xcode/xcuserdata/carterfoughty.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/carterfoughty.xcuserdatad/xcschemes/xcschememanagement.plist index 269fa7e..c7b1111 100644 --- a/.swiftpm/xcode/xcuserdata/carterfoughty.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/.swiftpm/xcode/xcuserdata/carterfoughty.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,13 @@ 0 + SuppressBuildableAutocreation + + SwiftRepo + + primary + + + diff --git a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift index 66ab906..05bb43d 100644 --- a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift +++ b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift @@ -30,7 +30,7 @@ public class SwiftDataStore: Store where Model: PersistentMod public init(modelContainer: ModelContainer, merge: Merge?) { self.modelContainer = modelContainer self.merge = merge - self.timestampStore = TimestampStore(url: modelContainer.configurations.first?.url, modelType: Model.self) + self.timestampStore = TimestampStore(modelType: Model.self) } @MainActor diff --git a/Sources/SwiftRepo/Repository/Store/TimestampStore.swift b/Sources/SwiftRepo/Repository/Store/TimestampStore.swift index 79539b0..11d0e99 100644 --- a/Sources/SwiftRepo/Repository/Store/TimestampStore.swift +++ b/Sources/SwiftRepo/Repository/Store/TimestampStore.swift @@ -14,44 +14,47 @@ class TimestampStore: Store { var keys: [Key] { get throws { - try modelContext.fetch(FetchDescriptor()).map { $0.key } + try modelContext.fetch(FetchDescriptor()).compactMap { + guard let key = try? JSONDecoder().decode(Key.self, from: $0.key) else { + try? evict(for: $0.key) + return nil + } + return key + } } } - init(url: URL?, modelType: T.Type) { + init(modelType: T.Type) { let storeName: String = "TimestampStore-\(String(describing: modelType))" - if let url { - let modelContainer = try! ModelContainer( - for: Timestamp.self, - configurations: .init(storeName, url: url) - ) - modelContext = ModelContext(modelContainer) - } else { - let modelContainer = try! ModelContainer( - for: Timestamp.self, - configurations: .init(storeName) - ) - modelContext = ModelContext(modelContainer) - } + let modelContainer = try! ModelContainer( + for: Timestamp.self, + configurations: .init(storeName) + ) + modelContext = ModelContext(modelContainer) } func get(key: Key) throws -> Date? { - return try modelContext.fetch(FetchDescriptor(predicate: Timestamp.predicate(forKey: key))).first?.timestamp + let keyData = try JSONEncoder().encode(key) + return try modelContext.fetch( + FetchDescriptor(predicate: Timestamp.predicate(forKeyData: keyData)) + ).first?.timestamp } @discardableResult func set(key: Key, value: Value?) throws -> Value? { if let value { - modelContext.insert(Timestamp(key: key, timestamp: value)) + try modelContext.insert(Timestamp(key: key, timestamp: value)) try modelContext.save() } else { - try evict(for: key) + let keyData = try JSONEncoder().encode(key) + try evict(for: keyData) } return value } func age(of key: Key) throws -> TimeInterval? { - let result = try modelContext.fetch(FetchDescriptor(predicate: Timestamp.predicate(forKey: key))) + let keyData = try JSONEncoder().encode(key) + let result = try modelContext.fetch(FetchDescriptor(predicate: Timestamp.predicate(forKeyData: keyData))) guard let result = result.first else { return nil } return Date.now.timeIntervalSince(result.timestamp) } @@ -68,16 +71,17 @@ class TimestampStore: Store { #Index([\.key]) @Attribute(.unique) - var key: Key + var key: Data var timestamp: Date - init(key: Key, timestamp: Date) { + init(key: Key, timestamp: Date) throws { + let key: Data = try JSONEncoder().encode(key) self.key = key self.timestamp = timestamp } - static func predicate(forKey key: Key) -> Predicate { - #Predicate { $0.key == key } + static func predicate(forKeyData keyData: Data) throws -> Predicate { + return #Predicate { $0.key == keyData } } } @@ -87,8 +91,8 @@ class TimestampStore: Store { // MARK: - Helpers - private func evict(for key: Key) throws { - try modelContext.delete(model: Timestamp.self, where: Timestamp.predicate(forKey: key)) + private func evict(for keyData: Data) throws { + try modelContext.delete(model: Timestamp.self, where: Timestamp.predicate(forKeyData: keyData)) try modelContext.save() } } From c071ace537ea5f65883daed70864c83e8a8b496c Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Thu, 3 Oct 2024 14:15:48 -0500 Subject: [PATCH 07/12] MainActor get --- .../Protocols/Repository/ConstantQueryRepository.swift | 2 ++ .../Repository/Protocols/Repository/QueryRepository.swift | 2 ++ .../Protocols/Repository/VariableQueryRepository.swift | 2 ++ .../Repository/Repository/DefaultConstantQueryRepository.swift | 1 + .../Repository/Repository/DefaultQueryRepository.swift | 1 + .../Repository/Repository/DefaultVariableQueryRepository.swift | 1 + .../SwiftRepo/Repository/Repository/PagedQueryRepository.swift | 3 ++- 7 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftRepo/Repository/Protocols/Repository/ConstantQueryRepository.swift b/Sources/SwiftRepo/Repository/Protocols/Repository/ConstantQueryRepository.swift index fb5db96..c6e17de 100644 --- a/Sources/SwiftRepo/Repository/Protocols/Repository/ConstantQueryRepository.swift +++ b/Sources/SwiftRepo/Repository/Protocols/Repository/ConstantQueryRepository.swift @@ -23,6 +23,7 @@ public protocol ConstantQueryRepository: HasValueResult { /// - willGet: a callback that is invoked if the query is performed. /// /// When using a loading controller, the function `loadingController.loading` should be passed to the `willGet` parameter. + @MainActor func get(errorIntent: ErrorIntent, queryStrategy: QueryStrategy?, willGet: @escaping Query.WillGet) async /// Publishes results. The publisher's first element will be the currently stored value, if any, at the time of the `publisher()` call. @@ -43,6 +44,7 @@ public protocol ConstantQueryRepository: HasValueResult { public extension ConstantQueryRepository { + @MainActor func get( errorIntent: ErrorIntent, queryStrategy: QueryStrategy? = nil, diff --git a/Sources/SwiftRepo/Repository/Protocols/Repository/QueryRepository.swift b/Sources/SwiftRepo/Repository/Protocols/Repository/QueryRepository.swift index 34a6c33..ffce07b 100644 --- a/Sources/SwiftRepo/Repository/Protocols/Repository/QueryRepository.swift +++ b/Sources/SwiftRepo/Repository/Protocols/Repository/QueryRepository.swift @@ -31,6 +31,7 @@ public protocol QueryRepository: HasValueResult /// - willGet: a callback that is invoked if the query is performed. /// /// When using a loading controller, the function `loadingController.loading` should be passed to the `willGet` parameter. + @MainActor func get( queryId: QueryId, variables: Variables, @@ -63,6 +64,7 @@ public protocol QueryRepository: HasValueResult public extension QueryRepository { + @MainActor func get( queryId: QueryId, variables: Variables, diff --git a/Sources/SwiftRepo/Repository/Protocols/Repository/VariableQueryRepository.swift b/Sources/SwiftRepo/Repository/Protocols/Repository/VariableQueryRepository.swift index 40b444a..6b151b2 100644 --- a/Sources/SwiftRepo/Repository/Protocols/Repository/VariableQueryRepository.swift +++ b/Sources/SwiftRepo/Repository/Protocols/Repository/VariableQueryRepository.swift @@ -24,6 +24,7 @@ public protocol VariableQueryRepository: HasValueResult { /// - willGet: a callback that is invoked if the query is performed. /// /// When using a loading controller, the function `loadingController.loading` should be passed to the `willGet` parameter. + @MainActor func get( variables: Variables, errorIntent: ErrorIntent, @@ -49,6 +50,7 @@ public protocol VariableQueryRepository: HasValueResult { public extension VariableQueryRepository { + @MainActor func get( variables: Variables, errorIntent: ErrorIntent, diff --git a/Sources/SwiftRepo/Repository/Repository/DefaultConstantQueryRepository.swift b/Sources/SwiftRepo/Repository/Repository/DefaultConstantQueryRepository.swift index 896055e..f704e2b 100644 --- a/Sources/SwiftRepo/Repository/Repository/DefaultConstantQueryRepository.swift +++ b/Sources/SwiftRepo/Repository/Repository/DefaultConstantQueryRepository.swift @@ -62,6 +62,7 @@ public final class DefaultConstantQueryRepository: ConstantQue // MARK: - ConstantQueryRepository + @MainActor public func get( errorIntent: ErrorIntent, queryStrategy: QueryStrategy?, diff --git a/Sources/SwiftRepo/Repository/Repository/DefaultQueryRepository.swift b/Sources/SwiftRepo/Repository/Repository/DefaultQueryRepository.swift index 3e4152b..28f709c 100644 --- a/Sources/SwiftRepo/Repository/Repository/DefaultQueryRepository.swift +++ b/Sources/SwiftRepo/Repository/Repository/DefaultQueryRepository.swift @@ -222,6 +222,7 @@ where QueryId: Hashable, Variables: Hashable, Key: Hashable { // MARK: - QueryRepository + @MainActor public func get( queryId: QueryId, variables: Variables, diff --git a/Sources/SwiftRepo/Repository/Repository/DefaultVariableQueryRepository.swift b/Sources/SwiftRepo/Repository/Repository/DefaultVariableQueryRepository.swift index 2e57f68..818b3da 100644 --- a/Sources/SwiftRepo/Repository/Repository/DefaultVariableQueryRepository.swift +++ b/Sources/SwiftRepo/Repository/Repository/DefaultVariableQueryRepository.swift @@ -68,6 +68,7 @@ public final class DefaultVariableQueryRepository: VariableQue // MARK: - VariableQueryRepository + @MainActor public func get( variables: Variables, errorIntent: ErrorIntent, diff --git a/Sources/SwiftRepo/Repository/Repository/PagedQueryRepository.swift b/Sources/SwiftRepo/Repository/Repository/PagedQueryRepository.swift index 44922a1..fd86d10 100644 --- a/Sources/SwiftRepo/Repository/Repository/PagedQueryRepository.swift +++ b/Sources/SwiftRepo/Repository/Repository/PagedQueryRepository.swift @@ -28,6 +28,7 @@ public final class PagedQueryRepository: QueryRe public typealias ValueVariablesFactory = (_ queryId: QueryId, _ variables: Variables, _ value: Value) -> Variables + @MainActor public func get( queryId: QueryId, variables: Variables, @@ -38,7 +39,7 @@ public final class PagedQueryRepository: QueryRe // Evict stale data when getting the first page. if !variables.isPaging { let key = keyFactory(queryId, variables) - try await observableStore.evict(for: key, ifOlderThan: ifOlderThan) + try observableStore.evict(for: key, ifOlderThan: ifOlderThan) } // Ignore the `queryStrategy` parameter for now, forcing `.ifNotStored`. No other strategy makes sense with paging. try await repository.get( From ba16ccca5dfdf4721d0c9f453d12e6df84c9d851 Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Thu, 3 Oct 2024 17:04:29 -0500 Subject: [PATCH 08/12] Updated persistent store --- .../Repository/Store/PersistentStore.swift | 189 ------------------ .../Repository/Store/PersistentValue.swift | 46 ----- .../Repository/Store/TimestampStore.swift | 82 +++++--- 3 files changed, 54 insertions(+), 263 deletions(-) delete mode 100644 Sources/SwiftRepo/Repository/Store/PersistentStore.swift delete mode 100644 Sources/SwiftRepo/Repository/Store/PersistentValue.swift diff --git a/Sources/SwiftRepo/Repository/Store/PersistentStore.swift b/Sources/SwiftRepo/Repository/Store/PersistentStore.swift deleted file mode 100644 index 7ff723b..0000000 --- a/Sources/SwiftRepo/Repository/Store/PersistentStore.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// Created by Timothy Moose on 2/10/24. -// - -import OSLog -import Foundation - -// TODO there are a lot of things in here that should be done async and should throw but we need to modify the `Store` API -// and there are probably massive ripple effects. For now, we use the `PersistentValue` type to defer async loading. -// and any filed save operations will not be handled properly. - -/// A persistent store implementation that supports an optional 2nd-level storage for in-memory caching. -public final actor PersistentStore: Store { - - // MARK: - API - - /// The key is required to be a string so we can easily use it as the filename. Otherwise, we need to add a mapping from - /// filename to `Key` for the `keys` API. - public typealias Key = String - - /// A closure that knows how to load a file URL of type `Wrapped`. - public typealias Load = (URL) async throws -> Wrapped - - /// A closure that knows how to save type `Wrapped` to a file URL. - public typealias Save = (Wrapped, URL) async throws -> Void - - /// A second level of storage intended for in-memory faster retrieval. Typically this would be an `NSCacheStore`. - public typealias SecondLevelStore = any Store - - public enum Location { - case documents(subpath: [String]) - case cache(subpath: [String]) - - var directoryURL: URL { - var url: URL - switch self { - case .documents: url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - case .cache: url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! - } - for component in subpath { - url = url.appendingPathComponent(component) - } - return url - } - - var subpath: [String] { - switch self { - case .cache(let subpath): subpath - case .documents(let subpath): subpath - } - } - } - - // Create a `Data` store - public init(location: Location, secondLevelStore: SecondLevelStore? = nil) where Wrapped == Data { - print("PersistentStore location=\(location.directoryURL)") - self.init( - load: { url in - try Data(contentsOf: url) - }, - save: { data, url in - try data.write(to: url) - }, - location: location, - secondLevelStore: secondLevelStore - ) - } - - // Create a store. - public init( - load: @escaping Load, - save: @escaping Save, - location: Location, - secondLevelStore: SecondLevelStore? = nil - ) { - self.load = load - self.save = save - self.location = location - self.secondLevelStore = secondLevelStore - do { - try FileManager.default.createDirectory(at: location.directoryURL, withIntermediateDirectories: true) - } catch { - self.logger.error("\(error)") - } - } - - // MARK: - Constants - - // MARK: - Variables - - private let load: Load - private let save: Save - private let location: Location - private let secondLevelStore: (any Store)? - private let logger = Logger(subsystem: "SwiftRepo", category: "PersistentStore") - - // MARK: - Store - - public typealias Value = PersistentValue - - @MainActor - public func set(key: Key, value: Value?) throws -> Value? { - switch value { - case let value?: - guard let wrapped = value.wrapped else { return nil } - try secondLevelStore?.set(key: key, value: wrapped) - try save(wrapped: wrapped, url: url(for: key)) - return value - case .none: - try secondLevelStore?.set(key: key, value: nil) - let url = url(for: key) - try FileManager.default.removeItem(atPath: url.path) - return nil - } - } - - @MainActor - public func get(key: Key) throws -> Value? { - if let wrapped = try secondLevelStore?.get(key: key) { return PersistentValue(initial: .wrapped(wrapped)) } - let url = url(for: key) - switch FileManager.default.fileExists(atPath: url.path) { - case true: return load(url: url) - case false: return nil - } - } - - @MainActor - public func age(of key: Key) throws -> TimeInterval? { - let url = url(for: key) - let attr = try FileManager.default.attributesOfItem(atPath: url.path) - guard let date = attr[FileAttributeKey.modificationDate] as? Date else { return nil } - return Date().timeIntervalSince(date) - } - - public func clear() async throws { - try await secondLevelStore?.clear() - try FileManager.default.removeItem(atPath: location.directoryURL.path) - fatalError("TODO delete the directory") - } - - @MainActor - public var keys: [Key] { - (try? FileManager.default.contentsOfDirectory(atPath: location.directoryURL.path)) ?? [] - } - - // MARK: - File management - - @MainActor - private func url(for key: Key) -> URL { - let url = location.directoryURL - return url.appendingPathComponent(key) - } - - @MainActor - private func load(url: URL) -> Value { - PersistentValue(initial: .load { - do { - return try await withUnsafeThrowingContinuation { continuation in - Task.detached(priority: .medium) { - let value = try await self.load(url) - continuation.resume(returning: value) - } - } - } catch { - self.logger.error("\(error)") - throw error - } - }) - } - - @MainActor - private func save(wrapped: Wrapped, url: URL) { - Task.detached(priority: .medium) { - do { - try await self.save(wrapped, url) - } catch { - self.logger.error("\(error)") - throw error - } - } - } -} - -@globalActor -struct MediaActor { - actor ActorType { } - - static let shared: ActorType = ActorType() -} diff --git a/Sources/SwiftRepo/Repository/Store/PersistentValue.swift b/Sources/SwiftRepo/Repository/Store/PersistentValue.swift deleted file mode 100644 index 8b18887..0000000 --- a/Sources/SwiftRepo/Repository/Store/PersistentValue.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// Created by Timothy Moose on 2/10/24. -// - -import Foundation - -// Wraps a persistent value that may be in memory on disk -public class PersistentValue { - - // MARK: - API - - public func wrapped() async throws -> Wrapped { - if let wrapped { return wrapped } - switch initial { - case .wrapped(let wrapped): - self.wrapped = wrapped - return wrapped - case .load(let load): - let wrapped = try await load() - self.wrapped = wrapped - return wrapped - } - } - - public init(wrapped: Wrapped) { - self.initial = .wrapped(wrapped) - self.wrapped = wrapped - } - - enum Initial { - case wrapped(Wrapped) - case load(() async throws -> Wrapped) - } - - init(initial: Initial) { - self.initial = initial - } - - private(set) var wrapped: Wrapped? - - // MARK: - Constants - - // MARK: - Variables - - private let initial: Initial -} diff --git a/Sources/SwiftRepo/Repository/Store/TimestampStore.swift b/Sources/SwiftRepo/Repository/Store/TimestampStore.swift index 11d0e99..ba0feae 100644 --- a/Sources/SwiftRepo/Repository/Store/TimestampStore.swift +++ b/Sources/SwiftRepo/Repository/Store/TimestampStore.swift @@ -8,14 +8,13 @@ import Foundation import SwiftData -/// A `Store` implementation using `SwiftData` which can be used specifically to -/// track timestamps related to models persisted using the `SwiftDataStore`. -class TimestampStore: Store { +/// A persistent `Store` implementation implementation using `SwiftData`. +class PersistentStore: Store { var keys: [Key] { get throws { - try modelContext.fetch(FetchDescriptor()).compactMap { - guard let key = try? JSONDecoder().decode(Key.self, from: $0.key) else { + try modelContext.fetch(FetchDescriptor()).compactMap { + guard let key = try? decoder.decode(Key.self, from: $0.key) else { try? evict(for: $0.key) return nil } @@ -24,75 +23,102 @@ class TimestampStore: Store { } } - init(modelType: T.Type) { + init( + modelType: T.Type, + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder() + ) { let storeName: String = "TimestampStore-\(String(describing: modelType))" - let modelContainer = try! ModelContainer( - for: Timestamp.self, + self.modelContainer = try! ModelContainer( + for: TimestampedValue.self, configurations: .init(storeName) ) - modelContext = ModelContext(modelContainer) + self.encoder = encoder + self.decoder = decoder } - func get(key: Key) throws -> Date? { - let keyData = try JSONEncoder().encode(key) - return try modelContext.fetch( - FetchDescriptor(predicate: Timestamp.predicate(forKeyData: keyData)) - ).first?.timestamp + @MainActor + func get(key: Key) throws -> Value? { + let keyData = try encoder.encode(key) + guard let valueData = try modelContext.fetch( + FetchDescriptor(predicate: TimestampedValue.predicate(forKeyData: keyData)) + ).first else { return nil } + return try decoder.decode(Value.self, from: valueData.value) } @discardableResult + @MainActor func set(key: Key, value: Value?) throws -> Value? { if let value { - try modelContext.insert(Timestamp(key: key, timestamp: value)) + try modelContext.insert(TimestampedValue(key: key, value: value, encoder: encoder)) try modelContext.save() } else { - let keyData = try JSONEncoder().encode(key) + let keyData = try encoder.encode(key) try evict(for: keyData) } return value } + @MainActor func age(of key: Key) throws -> TimeInterval? { - let keyData = try JSONEncoder().encode(key) - let result = try modelContext.fetch(FetchDescriptor(predicate: Timestamp.predicate(forKeyData: keyData))) + let keyData = try encoder.encode(key) + let result = try modelContext.fetch(FetchDescriptor(predicate: TimestampedValue.predicate(forKeyData: keyData))) guard let result = result.first else { return nil } return Date.now.timeIntervalSince(result.timestamp) } + @MainActor func clear() async throws { - try modelContext.delete(model: Timestamp.self) + try modelContext.delete(model: TimestampedValue.self) try modelContext.save() } // MARK: - Constants @Model - class Timestamp { - #Index([\.key]) + class TimestampedValue { + #Index([\.key]) @Attribute(.unique) var key: Data - var timestamp: Date + var timestamp = Date() + var value: Data - init(key: Key, timestamp: Date) throws { - let key: Data = try JSONEncoder().encode(key) + init(key: Key, value: Value, encoder: JSONEncoder) throws { + let key: Data = try encoder.encode(key) + let value: Data = try encoder.encode(value) self.key = key - self.timestamp = timestamp + self.value = value } - static func predicate(forKeyData keyData: Data) throws -> Predicate { + static func predicate(forKeyData keyData: Data) throws -> Predicate { return #Predicate { $0.key == keyData } } } // MARK: - Variables - private let modelContext: ModelContext + private let modelContainer: ModelContainer + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private var _modelContext: ModelContext? + + @MainActor + private var modelContext: ModelContext { + if let _modelContext { + return _modelContext + } else { + let context = ModelContext(modelContainer) + _modelContext = context + return context + } + } // MARK: - Helpers + @MainActor private func evict(for keyData: Data) throws { - try modelContext.delete(model: Timestamp.self, where: Timestamp.predicate(forKeyData: keyData)) + try modelContext.delete(model: TimestampedValue.self, where: TimestampedValue.predicate(forKeyData: keyData)) try modelContext.save() } } From 4d44ae818675a5a005c06133527a4d7885761784 Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Thu, 3 Oct 2024 17:06:12 -0500 Subject: [PATCH 09/12] Make public --- .../Repository/Store/TimestampStore.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftRepo/Repository/Store/TimestampStore.swift b/Sources/SwiftRepo/Repository/Store/TimestampStore.swift index ba0feae..bdf412c 100644 --- a/Sources/SwiftRepo/Repository/Store/TimestampStore.swift +++ b/Sources/SwiftRepo/Repository/Store/TimestampStore.swift @@ -9,9 +9,9 @@ import Foundation import SwiftData /// A persistent `Store` implementation implementation using `SwiftData`. -class PersistentStore: Store { +public class PersistentStore: Store { - var keys: [Key] { + public var keys: [Key] { get throws { try modelContext.fetch(FetchDescriptor()).compactMap { guard let key = try? decoder.decode(Key.self, from: $0.key) else { @@ -23,7 +23,7 @@ class PersistentStore: Store { } } - init( + public init( modelType: T.Type, encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder() @@ -38,7 +38,7 @@ class PersistentStore: Store { } @MainActor - func get(key: Key) throws -> Value? { + public func get(key: Key) throws -> Value? { let keyData = try encoder.encode(key) guard let valueData = try modelContext.fetch( FetchDescriptor(predicate: TimestampedValue.predicate(forKeyData: keyData)) @@ -48,7 +48,7 @@ class PersistentStore: Store { @discardableResult @MainActor - func set(key: Key, value: Value?) throws -> Value? { + public func set(key: Key, value: Value?) throws -> Value? { if let value { try modelContext.insert(TimestampedValue(key: key, value: value, encoder: encoder)) try modelContext.save() @@ -60,7 +60,7 @@ class PersistentStore: Store { } @MainActor - func age(of key: Key) throws -> TimeInterval? { + public func age(of key: Key) throws -> TimeInterval? { let keyData = try encoder.encode(key) let result = try modelContext.fetch(FetchDescriptor(predicate: TimestampedValue.predicate(forKeyData: keyData))) guard let result = result.first else { return nil } @@ -68,7 +68,7 @@ class PersistentStore: Store { } @MainActor - func clear() async throws { + public func clear() async throws { try modelContext.delete(model: TimestampedValue.self) try modelContext.save() } From 7a12f5d2d1a97339eec07c97083e3010205eafa1 Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Thu, 3 Oct 2024 17:11:40 -0500 Subject: [PATCH 10/12] Cleanup --- .../{TimestampStore.swift => PersistentStore.swift} | 8 ++++---- Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) rename Sources/SwiftRepo/Repository/Store/{TimestampStore.swift => PersistentStore.swift} (95%) diff --git a/Sources/SwiftRepo/Repository/Store/TimestampStore.swift b/Sources/SwiftRepo/Repository/Store/PersistentStore.swift similarity index 95% rename from Sources/SwiftRepo/Repository/Store/TimestampStore.swift rename to Sources/SwiftRepo/Repository/Store/PersistentStore.swift index bdf412c..4450125 100644 --- a/Sources/SwiftRepo/Repository/Store/TimestampStore.swift +++ b/Sources/SwiftRepo/Repository/Store/PersistentStore.swift @@ -1,5 +1,5 @@ // -// TimestampStore.swift +// PersistentStore.swift // SwiftRepo // // Created by Carter Foughty on 10/2/24. @@ -23,12 +23,12 @@ public class PersistentStore: Store { } } - public init( - modelType: T.Type, + public init( + id: String, encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder() ) { - let storeName: String = "TimestampStore-\(String(describing: modelType))" + let storeName: String = "PersistentStore-\(id)" self.modelContainer = try! ModelContainer( for: TimestampedValue.self, configurations: .init(storeName) diff --git a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift index 05bb43d..5152ce3 100644 --- a/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift +++ b/Sources/SwiftRepo/Repository/Store/SwiftDataStore.swift @@ -30,7 +30,7 @@ public class SwiftDataStore: Store where Model: PersistentMod public init(modelContainer: ModelContainer, merge: Merge?) { self.modelContainer = modelContainer self.merge = merge - self.timestampStore = TimestampStore(modelType: Model.self) + self.timestampStore = PersistentStore(id: String(describing: Model.self)) } @MainActor @@ -50,7 +50,7 @@ public class SwiftDataStore: Store where Model: PersistentMod } try modelContext.save() // Update the timestamp store when values are updated - try timestampStore.set(key: key, value: Date()) + try timestampStore.set(key: key, value: UUID()) return value } @@ -61,8 +61,7 @@ public class SwiftDataStore: Store where Model: PersistentMod @MainActor public func age(of key: Key) throws -> TimeInterval? { - guard let updatedAt = try timestampStore.get(key: key) else { return nil } - return Date.now.timeIntervalSince(updatedAt) + try timestampStore.age(of: key) } @MainActor @@ -79,7 +78,7 @@ public class SwiftDataStore: Store where Model: PersistentMod private var _modelContext: ModelContext? private let modelContainer: ModelContainer private let merge: Merge? - private let timestampStore: TimestampStore + private let timestampStore: PersistentStore @MainActor private var modelContext: ModelContext { From f8cb398e69f35a40bf01ff30c2219852b85cd8b4 Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Thu, 3 Oct 2024 17:13:26 -0500 Subject: [PATCH 11/12] Unused codable --- Sources/SwiftRepo/Repository/Unused.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftRepo/Repository/Unused.swift b/Sources/SwiftRepo/Repository/Unused.swift index a88473e..8e6b148 100644 --- a/Sources/SwiftRepo/Repository/Unused.swift +++ b/Sources/SwiftRepo/Repository/Unused.swift @@ -11,6 +11,6 @@ import Foundation /// `Never` type in Combine. For example, the `DefaultQueryRepository` type has three generic types `QueryId`, `Variables` and `Value`. /// The query ID allows the repository to manage multiple separate queries to the same endpoint. If there is only one distinct query, the `Unused` type /// can be specified for the `QueryId` parameter instead of using some other arbitrary constant, e.g. `DefaultQueryRepository`. -public enum Unused: Hashable { +public enum Unused: Hashable, Codable { case unused } From 2ca2857595af44571740ff50e4bd422b18024bcb Mon Sep 17 00:00:00 2001 From: Carter Foughty Date: Thu, 3 Oct 2024 17:34:48 -0500 Subject: [PATCH 12/12] End to end test --- .../Tests/DefaultQueryRepositoryTests.swift | 76 ++++++++++++++----- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/Sources/Tests/DefaultQueryRepositoryTests.swift b/Sources/Tests/DefaultQueryRepositoryTests.swift index 1c28452..2f943f6 100644 --- a/Sources/Tests/DefaultQueryRepositoryTests.swift +++ b/Sources/Tests/DefaultQueryRepositoryTests.swift @@ -20,14 +20,14 @@ class DefaultQueryRepositoryTests: XCTestCase { ]) var willGetCount = 0 let willGet = { willGetCount += 1 } - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) XCTAssertEqual(willGetCount, 1) XCTAssertEqual(spy.publishedValues, [valueA1]) try await Task.sleep(for: .seconds(0.05)) - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) XCTAssertEqual(willGetCount, 1) try await Task.sleep(for: .seconds(0.1)) - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) XCTAssertEqual(willGetCount, 2) XCTAssertEqual(spy.publishedValues, [valueA1, valueA1, valueA1, valueA2]) } @@ -43,15 +43,15 @@ class DefaultQueryRepositoryTests: XCTestCase { let spy = PublisherSpy(repo.publisher(for: id, setCurrent: id).success()) var willGetCount = 0 let willGet = { willGetCount += 1 } - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) XCTAssertEqual(willGetCount, 1) XCTAssertEqual(spy.publishedValues, [responseA.value]) XCTAssertEqual(try modelStore.get(key: Self.modelAId), responseA.models.first) try await Task.sleep(for: .seconds(0.05)) - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) XCTAssertEqual(willGetCount, 1) try await Task.sleep(for: .seconds(0.1)) - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) XCTAssertEqual(willGetCount, 2) XCTAssertEqual(spy.publishedValues, [responseA.value, responseA.value, responseA.value, responseB.value]) XCTAssertEqual(try modelStore.get(key: Self.modelAId), responseA.models.first) @@ -65,7 +65,7 @@ class DefaultQueryRepositoryTests: XCTestCase { delayedValues = DelayedValues(values: [ .makeError(TestError(category: .failure), delay: 0.1), ]) - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable) {} + await repo.get(queryId: id, variables: id, errorIntent: .indispensable) {} try await Task.sleep(for: .seconds(0.1)) XCTAssertEqual(spy.publishedValues.compactMap { $0 as? TestError }, [TestError(category: .failure)]) } @@ -77,7 +77,7 @@ class DefaultQueryRepositoryTests: XCTestCase { ]) ) let spy = PublisherSpy(await repo.publisher(for: id, setCurrent: id).failure()) - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable) {} + await repo.get(queryId: id, variables: id, errorIntent: .indispensable) {} try await Task.sleep(for: .seconds(0.1)) XCTAssertEqual(spy.publishedValues.compactMap { $0 as? TestError }, [TestError(category: .failure)]) } @@ -88,7 +88,7 @@ class DefaultQueryRepositoryTests: XCTestCase { delayedValues = DelayedValues(values: [ .makeValue(valueA1, delay: 0.1), ]) - try await repo.prefetch(queryId: id, variables: id) + await repo.prefetch(queryId: id, variables: id) XCTAssertEqual(spy.publishedValues, [valueA1]) } @@ -97,7 +97,7 @@ class DefaultQueryRepositoryTests: XCTestCase { delayedValues = DelayedValues(values: []) var willGetCount = 0 let willGet = { willGetCount += 1 } - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) XCTAssertEqual(willGetCount, 0) } @@ -111,11 +111,11 @@ class DefaultQueryRepositoryTests: XCTestCase { ]) var willGetCount = 0 let willGet = { willGetCount += 1 } - try await repo.get(queryId: id, variables: variablesA, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: variablesA, errorIntent: .indispensable, willGet: willGet) try await Task.sleep(for: .seconds(0.05)) - try await repo.get(queryId: id, variables: variablesB, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: variablesB, errorIntent: .indispensable, willGet: willGet) try await Task.sleep(for: .seconds(0.05)) - try await repo.get(queryId: id, variables: variablesA, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: variablesA, errorIntent: .indispensable, willGet: willGet) try await Task.sleep(for: .seconds(0.05)) XCTAssertEqual(willGetCount, 2) XCTAssertEqual(spy.publishedValues, [valueA1, valueB1, valueA1]) @@ -132,12 +132,12 @@ class DefaultQueryRepositoryTests: XCTestCase { ]) var willGetCount = 0 let willGet = { willGetCount += 1 } - try await repo.get(queryId: id, variables: variablesA, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: variablesA, errorIntent: .indispensable, willGet: willGet) try await Task.sleep(for: .seconds(0.05)) - try await repo.get(queryId: id, variables: variablesB, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: variablesB, errorIntent: .indispensable, willGet: willGet) // Wait long enough for stored values to be stale try await Task.sleep(for: .seconds(0.05)) - try await repo.get(queryId: id, variables: variablesA, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: variablesA, errorIntent: .indispensable, willGet: willGet) try await Task.sleep(for: .seconds(0.05)) XCTAssertEqual(willGetCount, 3) XCTAssertEqual(spy.publishedValues, [valueA1, valueB1, valueA1, valueA2]) @@ -150,8 +150,8 @@ class DefaultQueryRepositoryTests: XCTestCase { ]) var willGetCount = 0 let willGet = { willGetCount += 1 } - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, queryStrategy: .never, willGet: willGet) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: willGet) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, queryStrategy: .never, willGet: willGet) XCTAssertEqual(willGetCount, 1) } @@ -163,12 +163,36 @@ class DefaultQueryRepositoryTests: XCTestCase { .makeError(TestError(category: .failure), delay: 0.1), ]) let spy = PublisherSpy(await repo.publisher(for: id, setCurrent: keyA)) - try await repo.get(queryId: id, variables: id, errorIntent: .dispensable, willGet: {}) - try await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: {}) + await repo.get(queryId: id, variables: id, errorIntent: .dispensable, willGet: {}) + await repo.get(queryId: id, variables: id, errorIntent: .indispensable, willGet: {}) try await arbitraryWait() XCTAssertTrue((spy.publishedValues.first?.failure as? any AppError)?.intent == .dispensable) XCTAssertTrue((spy.publishedValues.last?.failure as? any AppError)?.intent == .indispensable) } + + func test_valuesWithIdenticalKeysPublish() async throws { + let repo = makeStoreRepository(queryStrategy: .always) + delayedValues = DelayedValues(values: [ + .makeValue(valueA1, delay: 0.1), + .makeValue(valueA2, delay: 0.1), + ]) + let loadingController = await LoadingController() + + await repo.publisher(for: .unused, setCurrent: .unused) + .receive(subscriber: loadingController.resultSubscriber) + + let spy = PublisherSpy(await repo.publisher(for: .unused, setCurrent: .unused).success()) + let loadingControllerSpy = await PublisherSpy(loadingController.state) + await repo.get(queryId: .unused, variables: .unused, errorIntent: .dispensable, willGet: {}) + await repo.get(queryId: .unused, variables: .unused, errorIntent: .indispensable, willGet: {}) + try await arbitraryWait() + XCTAssertEqual(spy.publishedValues, [valueA1, valueA1, valueA2]) + XCTAssertEqual( + loadingControllerSpy.publishedValues, + [.loading(isHidden: false), .loaded(valueA1, nil, isUpdating: false), + .loaded(valueA1, nil, isUpdating: false), .loaded(valueA2, nil, isUpdating: false)] + ) + } // MARK: - Constants @@ -228,6 +252,18 @@ class DefaultQueryRepositoryTests: XCTestCase { } } + /// Makes a repository that stores a single value per unique query ID. + private func makeStoreRepository( + queryStrategy: QueryStrategy = .ifOlderThan(0.1) + ) -> DefaultQueryRepository { + let observableStore = DefaultObservableStore( + store: DictionaryStore() + ) + return DefaultQueryRepository(observableStore: observableStore, queryStrategy: queryStrategy) { _ in + try await self.delayedValues.next() + } + } + /// Makes a repository that stores a single value per unique query ID, /// and places ModelResponse values in a separate model store. private func makeModelResponseStoreRepository(