diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6120009..8e2b648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,20 @@ name: "CI" -on: +on: push: - branches: + branches: - main pull_request: - branches: + branches: - '*' jobs: test: name: Unit Tests - runs-on: macOS-13 - env: - DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer + runs-on: macOS-26 + env: + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests run: swift test - \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..0b389e9 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "56d20ae0e9b26ba8fd8e419a8c54d6482d21800a8ce4cf2b6340fe42c6e3185e", + "pins" : [ + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 099a3e0..4724d17 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,4 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version: 6.0 import PackageDescription @@ -14,20 +13,26 @@ let package = Package( products: [ .library( name: "Papyrus", - targets: ["Papyrus"]), + targets: ["Papyrus"] + ) ], - dependencies: [], + dependencies: [.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.1")], targets: [ - .target(name: "Papyrus"), + .target( + name: "Papyrus", + dependencies: [ + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") + ] + ), .testTarget( name: "Unit", dependencies: ["Papyrus"], - exclude: ["Performance/Supporting Files/Unit.xctestplan"] + exclude: ["Supporting Files/Unit.xctestplan"] ), .testTarget( name: "Performance", dependencies: ["Papyrus"], - exclude: ["Performance/Supporting Files/Performance.xctestplan"] - ), + exclude: ["Supporting Files/Performance.xctestplan"] + ) ] ) diff --git a/Sources/Papyrus/Extensions/AsyncSequence.swift b/Sources/Papyrus/Extensions/AsyncSequence.swift deleted file mode 100644 index 3634faf..0000000 --- a/Sources/Papyrus/Extensions/AsyncSequence.swift +++ /dev/null @@ -1,10 +0,0 @@ -// Thanks - https://github.com/pointfreeco/swift-dependencies -extension AsyncSequence { - func eraseToStream() -> AsyncStream { - AsyncStream(self) - } - - func eraseToThrowingStream() -> AsyncThrowingStream { - AsyncThrowingStream(self) - } -} diff --git a/Sources/Papyrus/Extensions/AsyncStream.swift b/Sources/Papyrus/Extensions/AsyncStream.swift index 2d14122..3c7eb5c 100644 --- a/Sources/Papyrus/Extensions/AsyncStream.swift +++ b/Sources/Papyrus/Extensions/AsyncStream.swift @@ -1,12 +1,28 @@ // Thanks - https://github.com/pointfreeco/swift-dependencies -extension AsyncStream { - init(_ sequence: S) where S.Element == Element { - var iterator: S.AsyncIterator? - self.init { - if iterator == nil { - iterator = sequence.makeAsyncIterator() +extension AsyncStream where Element: Sendable { + init( + _ sequence: S + ) where S.Element == Element, S.Element: Sendable { + self.init { continuation in + let task = Task { + do { + for try await element in sequence { + continuation.yield(element) + } + continuation.finish() + } catch { + continuation.finish() + } + } + continuation.onTermination = { _ in + task.cancel() } - return try? await iterator?.next() } } } + +extension AsyncSequence where Self: Sendable, Element: Sendable { + func eraseToStream() -> AsyncStream { + AsyncStream(self) + } +} diff --git a/Sources/Papyrus/Extensions/AsyncThrowingStream.swift b/Sources/Papyrus/Extensions/AsyncThrowingStream.swift index 4d32a7b..23ffded 100644 --- a/Sources/Papyrus/Extensions/AsyncThrowingStream.swift +++ b/Sources/Papyrus/Extensions/AsyncThrowingStream.swift @@ -1,12 +1,28 @@ // Thanks - https://github.com/pointfreeco/swift-dependencies -extension AsyncThrowingStream where Failure == Error { - init(_ sequence: S) where S.Element == Element { - var iterator: S.AsyncIterator? - self.init { - if iterator == nil { - iterator = sequence.makeAsyncIterator() +extension AsyncThrowingStream where Element: Sendable, Failure == Error { + init( + _ sequence: S + ) where S.Element == Element, S.Element: Sendable { + self.init { continuation in + let task = Task { + do { + for try await element in sequence { + continuation.yield(element) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + task.cancel() } - return try await iterator?.next() } } } + +extension AsyncSequence where Self: Sendable, Element: Sendable { + func eraseToThrowingStream() -> AsyncThrowingStream { + AsyncThrowingStream(self) + } +} diff --git a/Sources/Papyrus/Fail.swift b/Sources/Papyrus/Fail.swift index fd674b3..4c7b843 100644 --- a/Sources/Papyrus/Fail.swift +++ b/Sources/Papyrus/Fail.swift @@ -34,7 +34,7 @@ struct Fail: AsyncSequence, Sendable where Failure: Error { /// Creates an async iterator that emits elements of this async sequence. /// - Returns: An instance that conforms to `AsyncIteratorProtocol`. func makeAsyncIterator() -> Self { - .init(error: self.error) + .init(error: error) } } @@ -44,9 +44,9 @@ extension Fail: AsyncIteratorProtocol { /// Produces the next element in the sequence. /// - Returns: The next element or `nil` if the end of the sequence is reached. mutating func next() async throws -> Element? { - defer { self.hasThownError = true } - guard !self.hasThownError else { return nil } + defer { hasThownError = true } + guard !hasThownError else { return nil } - throw self.error + throw error } } diff --git a/Sources/Papyrus/Just.swift b/Sources/Papyrus/Just.swift new file mode 100644 index 0000000..8596772 --- /dev/null +++ b/Sources/Papyrus/Just.swift @@ -0,0 +1,48 @@ +/// An asynchronous sequence that only emits the provided value once. +/// +/// ```swift +/// let stream = Just(1) +/// +/// for await value in stream { +/// print(value) +/// } +/// +/// // Prints: +/// // 1 +/// ``` +struct Just: AsyncSequence { + private let element: Element + private var emittedElement = false + + // MARK: Initialization + + /// Creates an async sequence that emits an element once. + /// - Parameters: + /// - element: The element to emit. + init(_ element: Element) { + self.element = element + } + + // MARK: AsyncSequence + + /// Creates an async iterator that emits elements of this async sequence. + /// - Returns: An instance that conforms to `AsyncIteratorProtocol`. + func makeAsyncIterator() -> Self { + .init(element) + } +} + +extension Just: Sendable where Element: Sendable {} + +// MARK: AsyncIteratorProtocol + +extension Just: AsyncIteratorProtocol { + /// Produces the next element in the sequence. + /// - Returns: The next element or `nil` if the end of the sequence is reached. + mutating func next() async -> Element? { + guard !emittedElement else { return nil } + defer { emittedElement = true } + + return element + } +} diff --git a/Sources/Papyrus/Logger.swift b/Sources/Papyrus/Logger.swift deleted file mode 100644 index ad843fa..0000000 --- a/Sources/Papyrus/Logger.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import os - -struct Logger { - let logLevel: LogLevel - - // Private - private let log: OSLog - - // MARK: Initialziation - - init(subsystem: String, category: String, logLevel: LogLevel = .info) { - self.logLevel = logLevel - self.log = OSLog(subsystem: subsystem, category: category) - } - - // MARK: API - - func info(_ message: String) { - self.log("â„šī¸ \(message)", level: .info) - } - - func debug(_ message: String) { - self.log("🔎 \(message)", level: .debug) - } - - func error(_ message: String) { - self.log("âš ī¸ \(message)", level: .error) - } - - func fault(_ message: String) { - self.log("đŸ”Ĩ \(message)", level: .fault) - } - - // MARK: Log - - private func log(_ message: String, level: LogLevel) { - guard - level >= self.logLevel, - let type = level.logType else { return } - os_log("%{public}@", log: self.log, type: type, message) - } -} - -// MARK: Log level - -public enum LogLevel: Int, Sendable { - case info - case debug - case error - case fault - case off - - var logType: OSLogType? { - switch self { - case .info: - return .info - case .debug: - return .debug - case .error: - return .error - case .fault: - return .fault - case .off: - return nil - } - } -} - -// MARK: Comparable - -extension LogLevel: Comparable { - public static func <(lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rawValue < rhs.rawValue } -} diff --git a/Sources/Papyrus/Observers/DirectoryObserver.swift b/Sources/Papyrus/Observers/DirectoryObserver.swift index 1b70da4..e4f3381 100644 --- a/Sources/Papyrus/Observers/DirectoryObserver.swift +++ b/Sources/Papyrus/Observers/DirectoryObserver.swift @@ -1,6 +1,6 @@ import Foundation -struct DirectoryObserver: Sendable { +struct DirectoryObserver : Sendable { private let url: URL // MARK: Initialization @@ -8,8 +8,8 @@ struct DirectoryObserver: Sendable { init(url: URL) throws { self.url = url - if !FileManager.default.fileExists(atPath: self.url.path) { - try FileManager.default.createDirectory(at: self.url, withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } } diff --git a/Sources/Papyrus/Observers/ObjectChange.swift b/Sources/Papyrus/Observers/ObjectChange.swift index 1655bf9..0fc151a 100644 --- a/Sources/Papyrus/Observers/ObjectChange.swift +++ b/Sources/Papyrus/Observers/ObjectChange.swift @@ -1,5 +1,7 @@ public enum ObjectChange: Equatable { - case deleted case changed(T) case created(T) + case deleted } + +extension ObjectChange: Sendable where T: Sendable {} diff --git a/Sources/Papyrus/Papyrus.swift b/Sources/Papyrus/Papyrus.swift index efe9a8e..3313736 100644 --- a/Sources/Papyrus/Papyrus.swift +++ b/Sources/Papyrus/Papyrus.swift @@ -6,11 +6,12 @@ import Foundation /// - `Codable` /// - `Equatable` /// - `Identifiable where ID: LosslessStringConvertible & Sendable` -public protocol Papyrus: Codable, Equatable, Identifiable where ID: LosslessStringConvertible & Sendable { } +public protocol Papyrus: Codable, Equatable, Identifiable +where ID: LosslessStringConvertible & Sendable { } // MARK: Helpers extension Papyrus { - var filename: String { String(self.id) } + var filename: String { String(id) } var typeDescription: String { String(describing: type(of: self)) } } diff --git a/Sources/Papyrus/PapyrusStore.swift b/Sources/Papyrus/PapyrusStore.swift index 7c880f0..6e0679e 100644 --- a/Sources/Papyrus/PapyrusStore.swift +++ b/Sources/Papyrus/PapyrusStore.swift @@ -1,4 +1,5 @@ import Foundation +import os /// A `PapyrusStore` is a data store for `Papyrus` conforming objects. /// @@ -15,14 +16,10 @@ public struct PapyrusStore: Sendable { /// Initialize a new `PapyrusStore` instance persisted at the provided `URL`. /// - Parameter url: The `URL` to persist data to. - public init(url: URL, logLevel: LogLevel = .off) { + public init(url: URL) { self.url = url - self.logger = Logger( - subsystem: "com.reddavis.PapyrusStore", - category: "PapyrusStore", - logLevel: logLevel - ) - self.setupDataDirectory() + self.logger = Logger(subsystem: "com.reddavis.PapyrusStore", category: "PapyrusStore") + setupDataDirectory() } /// Initialize a new `PapyrusStore` instance with the default @@ -30,18 +27,18 @@ public struct PapyrusStore: Sendable { /// /// The default Papyrus Store will persist it's data to a /// directory inside Application Support. - public init(logLevel: LogLevel = .off) { + public init() { let url = URL.applicationSupportDirectory.appendingPathComponent("Papyrus", isDirectory: true) - self.init(url: url, logLevel: logLevel) + self.init(url: url) } // MARK: Store management private func setupDataDirectory() { do { - try self.createDirectoryIfNeeded(at: self.url) + try createDirectoryIfNeeded(at: url) } catch { - self.logger.fault("Unable to create store directory: \(error)") + logger.fault("Unable to create store directory: \(error)") } } @@ -49,39 +46,39 @@ public struct PapyrusStore: Sendable { /// /// This will destroy and then rebuild the store's directory. public func reset() throws { - try self.fileManager.removeItem(at: self.url) - self.setupDataDirectory() + try fileManager.removeItem(at: url) + setupDataDirectory() } // MARK: File management private func fileURL(for typeDescription: String, id: ID) -> URL { - self.fileURL(for: typeDescription, filename: String(id)) + fileURL(for: typeDescription, filename: String(id)) } private func fileURL(for typeDescription: String, filename: String) -> URL { - self.directoryURL(for: typeDescription).appendingPathComponent(filename) + directoryURL(for: typeDescription).appendingPathComponent(filename) } private func directoryURL(for type: T.Type) -> URL { - self.directoryURL(for: String(describing: type)) + directoryURL(for: String(describing: type)) } private func directoryURL(for typeDescription: String) -> URL { - self.url.appendingPathComponent(typeDescription, isDirectory: true) + url.appendingPathComponent(typeDescription, isDirectory: true) } private func createDirectoryIfNeeded(for type: T.Type) throws { - try self.createDirectoryIfNeeded(for: String(describing: type)) + try createDirectoryIfNeeded(for: String(describing: type)) } private func createDirectoryIfNeeded(for typeDescription: String) throws { - try self.createDirectoryIfNeeded(at: self.directoryURL(for: typeDescription)) + try createDirectoryIfNeeded(at: directoryURL(for: typeDescription)) } private func createDirectoryIfNeeded(at url: URL) throws { var isDirectory = ObjCBool(false) - let exists = self.fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) + let exists = fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) // All good - directory already exists. if isDirectory.boolValue && exists { return } @@ -90,12 +87,12 @@ public struct PapyrusStore: Sendable { else if !isDirectory.boolValue && exists { throw SetupError.fileExistsInDirectoryURL(url) } // Create directory - try self.fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) - self.logger.debug("Created directory: \(url.absoluteString)") + try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + logger.debug("Created directory: \(url.absoluteString)") } private func setCreatedAt(_ timestamp: Date, for url: URL) throws { - try self.fileManager.setAttributes( + try fileManager.setAttributes( [.creationDate: timestamp], ofItemAtPath: url.path ) @@ -106,28 +103,28 @@ public struct PapyrusStore: Sendable { /// Saves the object to the store. /// - Parameter object: The object to save. public func save(_ object: T) throws { - try self.save(object, touchDirectory: true) + try save(object, touchDirectory: true) } private func save(_ object: T, touchDirectory: Bool) throws { do { - try self.createDirectoryIfNeeded(for: T.self) + try createDirectoryIfNeeded(for: T.self) - let data = try self.encoder.encode(object) - let url = self.fileURL(for: object.typeDescription, filename: object.filename) + let data = try encoder.encode(object) + let url = fileURL(for: object.typeDescription, filename: object.filename) try data.write(to: url) - self.logger.debug("Saved object \(object.typeDescription). filename: \(object.filename)]") + logger.debug("Saved object \(object.typeDescription). filename: \(object.filename)]") if touchDirectory { - let directoryURL = self.directoryURL(for: T.self) - self.logger.debug("Touching directory. url: \(directoryURL)") - try self.fileManager.setAttributes( + let directoryURL = directoryURL(for: T.self) + logger.debug("Touching directory. url: \(directoryURL)") + try fileManager.setAttributes( [.modificationDate: Date.now], ofItemAtPath: directoryURL.path ) } } catch { - self.logger.error("Failed to save. object: \(object.typeDescription) filename: \(object.filename)") + logger.error("Failed to save. object: \(object.typeDescription) filename: \(object.filename)") throw error } } @@ -142,14 +139,14 @@ public struct PapyrusStore: Sendable { for (index, object) in objects.enumerated() { group.addTask { - let url = self.fileURL(for: object.typeDescription, filename: object.filename) - let fileAlreadyExists = self.fileManager.fileExists(atPath: url.path) - try self.save(object, touchDirectory: false) + let url = fileURL(for: object.typeDescription, filename: object.filename) + let fileAlreadyExists = fileManager.fileExists(atPath: url.path) + try save(object, touchDirectory: false) if !fileAlreadyExists { // Because the aren't guaranteed to happen in order // we need to manually set the created at timstamp. - try self.setCreatedAt( + try setCreatedAt( timestamp.addingTimeInterval(TimeInterval(index) / 100000.0), for: url ) @@ -159,9 +156,9 @@ public struct PapyrusStore: Sendable { try await group.waitForAll() - let directoryURL = self.directoryURL(for: T.self) - self.logger.debug("Touching directory. url: \(directoryURL)") - try self.fileManager.setAttributes( + let directoryURL = directoryURL(for: T.self) + logger.debug("Touching directory. url: \(directoryURL)") + try fileManager.setAttributes( [.modificationDate: Date.now], ofItemAtPath: directoryURL.path ) @@ -177,8 +174,7 @@ public struct PapyrusStore: Sendable { public func object(id: ID) -> ObjectQuery { ObjectQuery( id: id, - directoryURL: self.directoryURL(for: T.self), - logLevel: self.logger.logLevel + directoryURL: directoryURL(for: T.self) ) } @@ -191,8 +187,7 @@ public struct PapyrusStore: Sendable { public func object(id: ID, of type: T.Type) -> ObjectQuery { ObjectQuery( id: id, - directoryURL: self.directoryURL(for: T.self), - logLevel: self.logger.logLevel + directoryURL: directoryURL(for: T.self) ) } @@ -202,8 +197,7 @@ public struct PapyrusStore: Sendable { /// - Returns: A `AnyPublisher<[T], Error>` instance. public func objects(type: T.Type) -> CollectionQuery { CollectionQuery( - directoryURL: self.directoryURL(for: T.self), - logLevel: self.logger.logLevel + directoryURL: directoryURL(for: T.self) ) } @@ -217,13 +211,13 @@ public struct PapyrusStore: Sendable { id: T.ID, of type: T.Type ) throws { - try self.delete(id: id, of: type, touchDirectory: true) + try delete(id: id, of: type, touchDirectory: true) } /// Deletes an object from the store. /// - Parameter object: The object to delete. public func delete(_ object: T) throws { - try self.delete(id: object.id, of: T.self, touchDirectory: true) + try delete(id: object.id, of: T.self, touchDirectory: true) } /// Deletes an array of objects. @@ -233,16 +227,16 @@ public struct PapyrusStore: Sendable { try await withThrowingTaskGroup(of: Void.self) { group in for object in objects { - group.addTask { - try self.delete(id: object.id, of: T.self, touchDirectory: false) + group.addTask { [id = object.id] in + try delete(id: id, of: T.self, touchDirectory: false) } } try await group.waitForAll() - let directoryURL = self.directoryURL(for: T.self) - self.logger.debug("Touching directory. url: \(directoryURL)") - try self.fileManager.setAttributes( + let directoryURL = directoryURL(for: T.self) + logger.debug("Touching directory. url: \(directoryURL)") + try fileManager.setAttributes( [.modificationDate: Date.now], ofItemAtPath: directoryURL.path ) @@ -250,7 +244,7 @@ public struct PapyrusStore: Sendable { } public func deleteAll(_ type: T.Type) throws { - try self.fileManager.removeItem(at: self.directoryURL(for: type)) + try fileManager.removeItem(at: directoryURL(for: type)) } private func delete( @@ -261,11 +255,11 @@ public struct PapyrusStore: Sendable { let objectType = String(describing: type) do { - let url = self.fileURL(for: objectType, id: id) - try self.fileManager.removeItem(at: url) - self.logger.debug("Deleted object \(objectType). id: \(id)") + let url = fileURL(for: objectType, id: id) + try fileManager.removeItem(at: url) + logger.debug("Deleted object \(objectType). id: \(id)") } catch { - self.logger.error( + logger.error( "Failed to delete. object: \(objectType) id: \(id), url: \(url)" ) throw error @@ -292,10 +286,10 @@ public struct PapyrusStore: Sendable { try await withThrowingTaskGroup(of: Void.self, body: { group in group.addTask { - try await self.delete(objects: objectsToDelete) + try await delete(objects: objectsToDelete) } group.addTask { - try await self.save(objects: objects) + try await save(objects: objects) } for try await _ in group {} // So we can throw errors @@ -315,7 +309,7 @@ public struct PapyrusStore: Sendable { /// of stored objects to merge into. public func merge( objects: [T], - into filter: @escaping (_ object: T) -> Bool + into filter: @Sendable @escaping (T) -> Bool ) async throws where T: Sendable { let objectIDs = objects.map(\.id) let objectsToDelete = self.objects(type: T.self) @@ -324,10 +318,10 @@ public struct PapyrusStore: Sendable { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { - try await self.delete(objects: objectsToDelete) + try await delete(objects: objectsToDelete) } group.addTask { - try await self.save(objects: objects) + try await save(objects: objects) } try await group.waitForAll() diff --git a/Sources/Papyrus/Queries/CollectionQuery.swift b/Sources/Papyrus/Queries/CollectionQuery.swift index 8e2c233..80a7e08 100644 --- a/Sources/Papyrus/Queries/CollectionQuery.swift +++ b/Sources/Papyrus/Queries/CollectionQuery.swift @@ -1,95 +1,110 @@ +import AsyncAlgorithms import Foundation +import os /// `PapyrusStore.CollectionQuery` is a mechanism for querying `Papyrus` objects. -public class CollectionQuery where T: Papyrus { - public typealias OnFilter = (T) -> Bool - public typealias OnSort = (T, T) -> Bool - +public struct CollectionQuery: Sendable where T: Papyrus { + public typealias OnFilter = @Sendable (T) -> Bool + public typealias OnSort = @Sendable (T, T) -> Bool + // Private private let decoder: JSONDecoder = .init() private let directoryURL: URL - private let fileManager = FileManager.default - private var filter: OnFilter? + private let filter: OnFilter? private let logger: Logger - private var sort: OnSort? - + private let sort: OnSort? + // MARK: Initialization - - init(directoryURL: URL, logLevel: LogLevel = .off) { + + init( + directoryURL: URL, + filter: OnFilter? = nil, + sort: OnSort? = nil + ) { self.directoryURL = directoryURL - self.logger = Logger( - subsystem: "com.reddavis.PapyrusStore", - category: "CollectionQuery", - logLevel: logLevel - ) + self.filter = filter + self.logger = Logger(subsystem: "com.reddavis.PapyrusStore", category: "CollectionQuery") + self.sort = sort } - + // MARK: API - + /// Executes the query. If filter or sort parameters are /// set, they will be applied to the results. /// - Returns: The results of the query. public func execute() -> [T] { - self.fetchObjects() + fetchObjects() } - + /// Apply a filter to the query. /// - Parameter onFilter: The filter to be applied. /// - Returns: The query item. - @discardableResult public func filter(_ onFilter: @escaping OnFilter) -> Self { - self.filter = onFilter - return self + .init( + directoryURL: directoryURL, + filter: onFilter, + sort: sort + ) } - + /// Apply a sort to the query. /// - Parameter onSort: The sort to be applied. /// - Returns: The query item. - @discardableResult public func sort(_ onSort: @escaping OnSort) -> Self { - self.sort = onSort - return self + .init( + directoryURL: directoryURL, + filter: filter, + sort: onSort + ) } - + /// Observe changes to the query. /// - Returns: A `AsyncThrowingStream` instance. - public func observe() -> AsyncThrowingStream<[T], Error> { + public func observe() -> AsyncThrowingStream<[T], Error> where T: Sendable { do { - let observer = try DirectoryObserver(url: self.directoryURL) + let observer = try DirectoryObserver(url: directoryURL) return observer.observe() - .map { _ in self.fetchObjects() } + .map { _ in fetchObjects() } .eraseToThrowingStream() } catch { return Fail(error: error) .eraseToThrowingStream() } } - + private func fetchObjects() -> [T] { do { - let filenames = try self.fileManager.contentsOfDirectory(atPath: self.directoryURL.path) + let fileManager = FileManager.default + let filenames = try fileManager.contentsOfDirectory(atPath: directoryURL.path) return filenames.reduce(into: [(Date, T)]()) { result, filename in + let url = directoryURL.appendingPathComponent(filename) do { - let url = self.directoryURL.appendingPathComponent(filename) let data = try Data(contentsOf: url) - let model = try self.decoder.decode(T.self, from: data) - let creationDate = try self.fileManager.attributesOfItem( + let model = try decoder.decode(T.self, from: data) + let creationDate = try fileManager.attributesOfItem( atPath: url.path )[.creationDate] as? Date ?? .now result.append((creationDate, model)) } catch { - self.logger.error("Failed to read cached data. error: \(error)") + logger.error("Failed to read cached data. error: \(error)") + do { + // Delete cached data + logger.debug("Deleting old cached data. url: \(url)") + try fileManager.removeItem(at: url) + } catch { + logger.error("Failed deleting old cached data. url: \(url) error: \(error)") + } } } .sorted { $0.0 < $1.0 } .map(\.1) - .filter(self.filter) - .sorted(by: self.sort) + .filter(filter) + .sorted(by: sort) } catch CocoaError.fileReadNoSuchFile { - self.logger.info("Failed to read contents of directory. url: \(self.directoryURL)") + logger.info("Failed to read contents of directory. url: \(directoryURL)") return [] } catch { - self.logger.fault("Unknown error occured. error: \(error)") + logger.fault("Unknown error occured. error: \(error)") return [] } } @@ -100,11 +115,11 @@ public class CollectionQuery where T: Papyrus { extension Sequence { fileprivate func filter(_ isIncluded: ((Element) -> Bool)?) -> [Element] { guard let isIncluded = isIncluded else { return Array(self) } - return self.filter { isIncluded($0) } + return filter { isIncluded($0) } } - + fileprivate func sorted(by areInIncreasingOrder: ((Element, Element) -> Bool)?) -> [Element] { guard let areInIncreasingOrder = areInIncreasingOrder else { return Array(self) } - return self.sorted { areInIncreasingOrder($0, $1) } + return sorted { areInIncreasingOrder($0, $1) } } } diff --git a/Sources/Papyrus/Queries/ObjectQuery.swift b/Sources/Papyrus/Queries/ObjectQuery.swift index fcd90da..71e503f 100644 --- a/Sources/Papyrus/Queries/ObjectQuery.swift +++ b/Sources/Papyrus/Queries/ObjectQuery.swift @@ -1,53 +1,53 @@ +import AsyncAlgorithms import Foundation +import os /// `ObjectQuery` is a mechanism for querying a single `Papyrus` object. -public class ObjectQuery { +public struct ObjectQuery: Sendable { private let decoder: JSONDecoder = .init() private let directoryURL: URL - private let fileManager = FileManager.default private let filename: String private let logger: Logger - + // MARK: Initialization - + init( id: ID, - directoryURL: URL, - logLevel: LogLevel = .off + directoryURL: URL ) { self.filename = String(id) self.directoryURL = directoryURL - self.logger = Logger( - subsystem: "com.reddavis.PapyrusStore", - category: "ObjectQuery", - logLevel: logLevel - ) + self.logger = Logger(subsystem: "com.reddavis.PapyrusStore", category: "ObjectQuery") } - + // MARK: API - + /// Executes the query. /// - Returns: The result of the query. public func execute() -> T? { - switch self.fetchObject() { + switch fetchObject() { case .success(let object): return object case .failure: return nil } } - + /// Observe changes to the query via an async stream. /// - Returns: A `AsyncThrowingStream` instance. - public func observe() -> AsyncThrowingStream, Error> { - var previousResult = self.fetchObject() + public func observe() -> AsyncThrowingStream, Error> where T: Sendable { do { - let observer = try DirectoryObserver(url: self.directoryURL) - return observer.observe() - .compactMap { _ in - let result = self.fetchObject() - defer { previousResult = result } - + let observer = try DirectoryObserver(url: directoryURL) + let object = fetchObject() + let observerSequence = observer.observe() + .map { fetchObject() } + + return chain(Just(object), observerSequence) + .pair() + .compactMap { tuple in + let previousResult = tuple.0 + let result = tuple.1 + switch (previousResult, result) { case (.success(let previousModel), .success(let model)) where previousModel != model: return .changed(model) @@ -65,26 +65,27 @@ public class ObjectQuery { .eraseToThrowingStream() } } - + private func fetchObject() -> Result { - let fileURL = self.directoryURL.appendingPathComponent(self.filename) - guard self.fileManager.fileExists(atPath: fileURL.path) else { - self.logger.info("Cached data not found. url: \(fileURL)") + let fileManager = FileManager.default + let fileURL = directoryURL.appendingPathComponent(filename) + guard fileManager.fileExists(atPath: fileURL.path) else { + logger.info("Cached data not found. url: \(fileURL)") return .failure(NotFoundError()) } - + do { let data = try Data(contentsOf: fileURL) return .success(try decoder.decode(T.self, from: data)) } catch { // Cached data is using an old schema. - self.logger.error("Failed to parse cached data. url: \(fileURL)") + logger.error("Failed to parse cached data. url: \(fileURL)") do { // Delete cached data - self.logger.debug("Deleting old cached data. url: \(fileURL)") - try self.fileManager.removeItem(at: fileURL) + logger.debug("Deleting old cached data. url: \(fileURL)") + try fileManager.removeItem(at: fileURL) } catch { - self.logger.error("Failed deleting old cached data. url: \(fileURL) error: \(error)") + logger.error("Failed deleting old cached data. url: \(fileURL) error: \(error)") return .failure(error) } return .failure(InvalidSchemaError(details: error)) @@ -100,3 +101,54 @@ extension ObjectQuery { var details: Error } } + +// MARK: AsyncPairSequence + +fileprivate struct AsyncPairSequence: AsyncSequence { + typealias AsyncIterator = Iterator + typealias Element = (Base.Element, Base.Element) + var base: Base + + func makeAsyncIterator() -> AsyncIterator { + Iterator(base: base.makeAsyncIterator()) + } +} + +extension AsyncPairSequence: Sendable where Base: Sendable, Element: Sendable {} + +// MARK: AsyncPairSequence Iterator + +extension AsyncPairSequence { + fileprivate struct Iterator: AsyncIteratorProtocol { + var base: Base.AsyncIterator + var lastValue: Base.Element? + + mutating func next() async rethrows -> (Base.Element, Base.Element)? { + guard let nextValue = try await base.next() else { + return nil + } + + guard let lastValue else { + lastValue = nextValue + guard let nextNextValue = try await base.next() else { + return nil + } + + lastValue = nextNextValue + return (nextValue, nextNextValue) + } + + defer { self.lastValue = nextValue } + return (lastValue, nextValue) + } + } +} + +extension AsyncPairSequence.Iterator: Sendable +where Base.AsyncIterator: Sendable, Element: Sendable {} + +extension AsyncSequence { + fileprivate func pair() -> AsyncPairSequence { + AsyncPairSequence(base: self) + } +} diff --git a/Tests/Unit/CollectionQueryTests.swift b/Tests/Unit/CollectionQueryTests.swift index ea4951c..a585b24 100644 --- a/Tests/Unit/CollectionQueryTests.swift +++ b/Tests/Unit/CollectionQueryTests.swift @@ -52,9 +52,9 @@ final class CollectionQueryTests: XCTestCase { } func test_filter_whenAppliedToStream() async throws { - Task { + Task { [directory] in try await Task.sleep(for: .milliseconds(10)) - try FileManager.default.poke(self.directory) + try FileManager.default.poke(directory!) } let collection = try await CollectionQuery(directoryURL: self.directory) @@ -66,9 +66,9 @@ final class CollectionQueryTests: XCTestCase { } func test_sort_whenAppliedToStream() async throws { - Task { + Task { [directory] in try await Task.sleep(for: .milliseconds(10)) - try FileManager.default.poke(self.directory) + try FileManager.default.poke(directory!) } let collection = try await CollectionQuery(directoryURL: self.directory) diff --git a/Tests/Unit/PapyrusStoreTests.swift b/Tests/Unit/PapyrusStoreTests.swift index d12be99..2a42b6e 100644 --- a/Tests/Unit/PapyrusStoreTests.swift +++ b/Tests/Unit/PapyrusStoreTests.swift @@ -13,7 +13,7 @@ final class PapyrusStoreTests: XCTestCase { UUID().uuidString, isDirectory: true ) - self.store = PapyrusStore(url: self.directory, logLevel: .info) + self.store = PapyrusStore(url: self.directory) } override func tearDown() {