From 092e31fa21b96c3c987d952349d997efb4dbefe9 Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 11 Jun 2024 14:24:07 +0100 Subject: [PATCH 01/12] prepare for StrictConcurrency --- Package.resolved | 23 ++++++ Package.swift | 22 ++++-- Sources/Papyrus/Just.swift | 48 ++++++++++++ Sources/Papyrus/Logger.swift | 4 +- .../Papyrus/Observers/DirectoryObserver.swift | 6 +- Sources/Papyrus/Observers/ObjectChange.swift | 2 +- Sources/Papyrus/PapyrusStore.swift | 6 +- Sources/Papyrus/Queries/CollectionQuery.swift | 46 +++++++---- Sources/Papyrus/Queries/ObjectQuery.swift | 76 ++++++++++++++++--- Tests/Unit/CollectionQueryTests.swift | 8 +- 10 files changed, 199 insertions(+), 42 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/Papyrus/Just.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..7142500 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "da4e36f86544cdf733a40d59b3a2267e3a7bbf36", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", + "version" : "1.1.1" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 099a3e0..0262a5a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,11 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version: 5.8 import PackageDescription +let settings: [SwiftSetting] = [ + .enableExperimentalFeature("StrictConcurrency") +] + let package = Package( name: "Papyrus", platforms: [ @@ -16,18 +19,25 @@ let package = Package( name: "Papyrus", targets: ["Papyrus"]), ], - dependencies: [], + dependencies: [.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0")], targets: [ - .target(name: "Papyrus"), + .target( + name: "Papyrus", + dependencies: [ + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ], + swiftSettings: settings + ), .testTarget( name: "Unit", dependencies: ["Papyrus"], - exclude: ["Performance/Supporting Files/Unit.xctestplan"] + exclude: ["Supporting Files/Unit.xctestplan"], + swiftSettings: settings ), .testTarget( name: "Performance", dependencies: ["Papyrus"], - exclude: ["Performance/Supporting Files/Performance.xctestplan"] + exclude: ["Supporting Files/Performance.xctestplan"] ), ] ) diff --git a/Sources/Papyrus/Just.swift b/Sources/Papyrus/Just.swift new file mode 100644 index 0000000..dd6fa34 --- /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(self.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 !self.emittedElement else { return nil } + defer { self.emittedElement = true } + + return self.element + } +} diff --git a/Sources/Papyrus/Logger.swift b/Sources/Papyrus/Logger.swift index ad843fa..01c4237 100644 --- a/Sources/Papyrus/Logger.swift +++ b/Sources/Papyrus/Logger.swift @@ -1,5 +1,5 @@ import Foundation -import os +@preconcurrency import os struct Logger { let logLevel: LogLevel @@ -42,6 +42,8 @@ struct Logger { } } +extension Logger: Sendable {} + // MARK: Log level public enum LogLevel: Int, Sendable { diff --git a/Sources/Papyrus/Observers/DirectoryObserver.swift b/Sources/Papyrus/Observers/DirectoryObserver.swift index 1b70da4..917ccf4 100644 --- a/Sources/Papyrus/Observers/DirectoryObserver.swift +++ b/Sources/Papyrus/Observers/DirectoryObserver.swift @@ -1,6 +1,6 @@ -import Foundation +@preconcurrency import Foundation -struct DirectoryObserver: Sendable { +struct DirectoryObserver { private let url: URL // MARK: Initialization @@ -37,3 +37,5 @@ struct DirectoryObserver: Sendable { } } } + +extension DirectoryObserver: Sendable {} diff --git a/Sources/Papyrus/Observers/ObjectChange.swift b/Sources/Papyrus/Observers/ObjectChange.swift index 1655bf9..6b12d18 100644 --- a/Sources/Papyrus/Observers/ObjectChange.swift +++ b/Sources/Papyrus/Observers/ObjectChange.swift @@ -1,5 +1,5 @@ public enum ObjectChange: Equatable { - case deleted case changed(T) case created(T) + case deleted } diff --git a/Sources/Papyrus/PapyrusStore.swift b/Sources/Papyrus/PapyrusStore.swift index 7c880f0..9dc6381 100644 --- a/Sources/Papyrus/PapyrusStore.swift +++ b/Sources/Papyrus/PapyrusStore.swift @@ -233,8 +233,8 @@ 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 self.delete(id: id, of: T.self, touchDirectory: false) } } @@ -315,7 +315,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) diff --git a/Sources/Papyrus/Queries/CollectionQuery.swift b/Sources/Papyrus/Queries/CollectionQuery.swift index 8e2c233..9e66545 100644 --- a/Sources/Papyrus/Queries/CollectionQuery.swift +++ b/Sources/Papyrus/Queries/CollectionQuery.swift @@ -1,27 +1,34 @@ +import AsyncAlgorithms import Foundation /// `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 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, + logLevel: LogLevel = .off, + sort: OnSort? = nil + ) { self.directoryURL = directoryURL + self.filter = filter self.logger = Logger( subsystem: "com.reddavis.PapyrusStore", category: "CollectionQuery", logLevel: logLevel ) + self.sort = sort } // MARK: API @@ -36,19 +43,25 @@ public class CollectionQuery where T: Papyrus { /// 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, + logLevel: logger.logLevel, + 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, + logLevel: logger.logLevel, + sort: onSort + ) } /// Observe changes to the query. @@ -67,13 +80,14 @@ public class CollectionQuery where T: Papyrus { 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: self.directoryURL.path) return filenames.reduce(into: [(Date, T)]()) { result, filename in 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 creationDate = try fileManager.attributesOfItem( atPath: url.path )[.creationDate] as? Date ?? .now result.append((creationDate, model)) @@ -95,6 +109,8 @@ public class CollectionQuery where T: Papyrus { } } +extension CollectionQuery: Sendable {} + // MARK: Sequence extension Sequence { diff --git a/Sources/Papyrus/Queries/ObjectQuery.swift b/Sources/Papyrus/Queries/ObjectQuery.swift index fcd90da..17f283c 100644 --- a/Sources/Papyrus/Queries/ObjectQuery.swift +++ b/Sources/Papyrus/Queries/ObjectQuery.swift @@ -1,10 +1,10 @@ +import AsyncAlgorithms import Foundation /// `ObjectQuery` is a mechanism for querying a single `Papyrus` object. -public class ObjectQuery { +public struct ObjectQuery { private let decoder: JSONDecoder = .init() private let directoryURL: URL - private let fileManager = FileManager.default private let filename: String private let logger: Logger @@ -39,14 +39,18 @@ public class ObjectQuery { /// 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 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: @@ -67,8 +71,9 @@ public class ObjectQuery { } private func fetchObject() -> Result { + let fileManager = FileManager.default let fileURL = self.directoryURL.appendingPathComponent(self.filename) - guard self.fileManager.fileExists(atPath: fileURL.path) else { + guard fileManager.fileExists(atPath: fileURL.path) else { self.logger.info("Cached data not found. url: \(fileURL)") return .failure(NotFoundError()) } @@ -82,7 +87,7 @@ public class ObjectQuery { do { // Delete cached data self.logger.debug("Deleting old cached data. url: \(fileURL)") - try self.fileManager.removeItem(at: fileURL) + try fileManager.removeItem(at: fileURL) } catch { self.logger.error("Failed deleting old cached data. url: \(fileURL) error: \(error)") return .failure(error) @@ -92,6 +97,8 @@ public class ObjectQuery { } } +extension ObjectQuery: Sendable {} + // MARK: Errors extension ObjectQuery { @@ -100,3 +107,52 @@ extension ObjectQuery { var details: Error } } + + +extension AsyncSequence { + func pair() -> AsyncPairSequence { + AsyncPairSequence(base: self) + } +} + +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: Iterator + +extension AsyncPairSequence { + 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 {} 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) From 832c178fb7fffa5a4936262a3e942b3e647b4eae Mon Sep 17 00:00:00 2001 From: Red Date: Wed, 12 Jun 2024 09:13:49 +0100 Subject: [PATCH 02/12] use the standard logger --- Sources/Papyrus/Logger.swift | 76 ------------------- Sources/Papyrus/PapyrusStore.swift | 22 ++---- Sources/Papyrus/Queries/CollectionQuery.swift | 10 +-- Sources/Papyrus/Queries/ObjectQuery.swift | 10 +-- Tests/Unit/PapyrusStoreTests.swift | 2 +- 5 files changed, 14 insertions(+), 106 deletions(-) delete mode 100644 Sources/Papyrus/Logger.swift diff --git a/Sources/Papyrus/Logger.swift b/Sources/Papyrus/Logger.swift deleted file mode 100644 index 01c4237..0000000 --- a/Sources/Papyrus/Logger.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -@preconcurrency 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) - } -} - -extension Logger: Sendable {} - -// 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/PapyrusStore.swift b/Sources/Papyrus/PapyrusStore.swift index 9dc6381..02d51b3 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,13 +16,9 @@ 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.logger = Logger(subsystem: "com.reddavis.PapyrusStore", category: "PapyrusStore") self.setupDataDirectory() } @@ -30,9 +27,9 @@ 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 @@ -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: self.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: self.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: self.directoryURL(for: T.self) ) } diff --git a/Sources/Papyrus/Queries/CollectionQuery.swift b/Sources/Papyrus/Queries/CollectionQuery.swift index 9e66545..82a2c8c 100644 --- a/Sources/Papyrus/Queries/CollectionQuery.swift +++ b/Sources/Papyrus/Queries/CollectionQuery.swift @@ -1,5 +1,6 @@ import AsyncAlgorithms import Foundation +import os /// `PapyrusStore.CollectionQuery` is a mechanism for querying `Papyrus` objects. public struct CollectionQuery where T: Papyrus { @@ -18,16 +19,11 @@ public struct CollectionQuery where T: Papyrus { init( directoryURL: URL, filter: OnFilter? = nil, - logLevel: LogLevel = .off, sort: OnSort? = nil ) { self.directoryURL = directoryURL self.filter = filter - self.logger = Logger( - subsystem: "com.reddavis.PapyrusStore", - category: "CollectionQuery", - logLevel: logLevel - ) + self.logger = Logger(subsystem: "com.reddavis.PapyrusStore", category: "CollectionQuery") self.sort = sort } @@ -47,7 +43,6 @@ public struct CollectionQuery where T: Papyrus { .init( directoryURL: directoryURL, filter: onFilter, - logLevel: logger.logLevel, sort: sort ) } @@ -59,7 +54,6 @@ public struct CollectionQuery where T: Papyrus { .init( directoryURL: directoryURL, filter: filter, - logLevel: logger.logLevel, sort: onSort ) } diff --git a/Sources/Papyrus/Queries/ObjectQuery.swift b/Sources/Papyrus/Queries/ObjectQuery.swift index 17f283c..c0a2177 100644 --- a/Sources/Papyrus/Queries/ObjectQuery.swift +++ b/Sources/Papyrus/Queries/ObjectQuery.swift @@ -1,5 +1,6 @@ import AsyncAlgorithms import Foundation +import os /// `ObjectQuery` is a mechanism for querying a single `Papyrus` object. public struct ObjectQuery { @@ -12,16 +13,11 @@ public struct ObjectQuery { 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 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() { From 595ea8eacaff18213142153ea88cfd4dfae97bb4 Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 22 Apr 2025 11:49:47 +0100 Subject: [PATCH 03/12] update swift tools version --- Package.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Package.swift b/Package.swift index 0262a5a..c30f5a0 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,7 @@ -// swift-tools-version: 5.8 +// swift-tools-version: 6.0 import PackageDescription -let settings: [SwiftSetting] = [ - .enableExperimentalFeature("StrictConcurrency") -] - let package = Package( name: "Papyrus", platforms: [ @@ -26,18 +22,16 @@ let package = Package( dependencies: [ .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ], - swiftSettings: settings ), .testTarget( name: "Unit", dependencies: ["Papyrus"], exclude: ["Supporting Files/Unit.xctestplan"], - swiftSettings: settings ), .testTarget( name: "Performance", dependencies: ["Papyrus"], exclude: ["Supporting Files/Performance.xctestplan"] ), - ] + ], ) From a3cd9b035674a2599a82666751aad7c21d43dd25 Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 22 Apr 2025 11:51:07 +0100 Subject: [PATCH 04/12] sendable safe async stream erasure --- .../Papyrus/Extensions/AsyncSequence.swift | 10 ------- Sources/Papyrus/Extensions/AsyncStream.swift | 30 ++++++++++++++----- .../Extensions/AsyncThrowingStream.swift | 30 ++++++++++++++----- Sources/Papyrus/Observers/ObjectChange.swift | 2 ++ Sources/Papyrus/Queries/CollectionQuery.swift | 2 +- 5 files changed, 49 insertions(+), 25 deletions(-) delete mode 100644 Sources/Papyrus/Extensions/AsyncSequence.swift 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/Observers/ObjectChange.swift b/Sources/Papyrus/Observers/ObjectChange.swift index 6b12d18..0fc151a 100644 --- a/Sources/Papyrus/Observers/ObjectChange.swift +++ b/Sources/Papyrus/Observers/ObjectChange.swift @@ -3,3 +3,5 @@ public enum ObjectChange: Equatable { case created(T) case deleted } + +extension ObjectChange: Sendable where T: Sendable {} diff --git a/Sources/Papyrus/Queries/CollectionQuery.swift b/Sources/Papyrus/Queries/CollectionQuery.swift index 82a2c8c..395e63f 100644 --- a/Sources/Papyrus/Queries/CollectionQuery.swift +++ b/Sources/Papyrus/Queries/CollectionQuery.swift @@ -60,7 +60,7 @@ public struct CollectionQuery where T: Papyrus { /// 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) return observer.observe() From 54ab185ce13af46e50c26e7119a30d7c932266e3 Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 22 Apr 2025 12:13:12 +0100 Subject: [PATCH 05/12] follow swift standard --- Sources/Papyrus/Fail.swift | 8 +- Sources/Papyrus/Just.swift | 8 +- .../Papyrus/Observers/DirectoryObserver.swift | 10 +- Sources/Papyrus/Papyrus.swift | 5 +- Sources/Papyrus/PapyrusStore.swift | 98 +++++++++---------- Sources/Papyrus/Queries/CollectionQuery.swift | 37 ++++--- Sources/Papyrus/Queries/ObjectQuery.swift | 21 ++-- 7 files changed, 95 insertions(+), 92 deletions(-) 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 index dd6fa34..8596772 100644 --- a/Sources/Papyrus/Just.swift +++ b/Sources/Papyrus/Just.swift @@ -28,7 +28,7 @@ struct Just: AsyncSequence { /// Creates an async iterator that emits elements of this async sequence. /// - Returns: An instance that conforms to `AsyncIteratorProtocol`. func makeAsyncIterator() -> Self { - .init(self.element) + .init(element) } } @@ -40,9 +40,9 @@ 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 !self.emittedElement else { return nil } - defer { self.emittedElement = true } + guard !emittedElement else { return nil } + defer { emittedElement = true } - return self.element + return element } } diff --git a/Sources/Papyrus/Observers/DirectoryObserver.swift b/Sources/Papyrus/Observers/DirectoryObserver.swift index 917ccf4..e4f3381 100644 --- a/Sources/Papyrus/Observers/DirectoryObserver.swift +++ b/Sources/Papyrus/Observers/DirectoryObserver.swift @@ -1,6 +1,6 @@ -@preconcurrency import Foundation +import Foundation -struct DirectoryObserver { +struct DirectoryObserver : Sendable { private let url: URL // MARK: Initialization @@ -8,8 +8,8 @@ struct DirectoryObserver { 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) } } @@ -37,5 +37,3 @@ struct DirectoryObserver { } } } - -extension DirectoryObserver: 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 02d51b3..6e0679e 100644 --- a/Sources/Papyrus/PapyrusStore.swift +++ b/Sources/Papyrus/PapyrusStore.swift @@ -19,7 +19,7 @@ public struct PapyrusStore: Sendable { public init(url: URL) { self.url = url self.logger = Logger(subsystem: "com.reddavis.PapyrusStore", category: "PapyrusStore") - self.setupDataDirectory() + setupDataDirectory() } /// Initialize a new `PapyrusStore` instance with the default @@ -36,9 +36,9 @@ public struct PapyrusStore: Sendable { 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)") } } @@ -46,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 } @@ -87,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 ) @@ -103,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 } } @@ -139,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 ) @@ -156,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 ) @@ -174,7 +174,7 @@ public struct PapyrusStore: Sendable { public func object(id: ID) -> ObjectQuery { ObjectQuery( id: id, - directoryURL: self.directoryURL(for: T.self) + directoryURL: directoryURL(for: T.self) ) } @@ -187,7 +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) + directoryURL: directoryURL(for: T.self) ) } @@ -197,7 +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) + directoryURL: directoryURL(for: T.self) ) } @@ -211,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. @@ -228,15 +228,15 @@ public struct PapyrusStore: Sendable { try await withThrowingTaskGroup(of: Void.self) { group in for object in objects { group.addTask { [id = object.id] in - try self.delete(id: id, of: T.self, touchDirectory: false) + 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 ) @@ -244,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( @@ -255,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 @@ -286,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 @@ -318,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 395e63f..5d91298 100644 --- a/Sources/Papyrus/Queries/CollectionQuery.swift +++ b/Sources/Papyrus/Queries/CollectionQuery.swift @@ -3,7 +3,7 @@ import Foundation import os /// `PapyrusStore.CollectionQuery` is a mechanism for querying `Papyrus` objects. -public struct CollectionQuery where T: Papyrus { +public struct CollectionQuery: Sendable where T: Papyrus { public typealias OnFilter = @Sendable (T) -> Bool public typealias OnSort = @Sendable (T, T) -> Bool @@ -33,7 +33,7 @@ public struct CollectionQuery where T: Papyrus { /// 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. @@ -62,9 +62,9 @@ public struct CollectionQuery where T: Papyrus { /// - Returns: A `AsyncThrowingStream` instance. 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) @@ -75,46 +75,51 @@ public struct CollectionQuery where T: Papyrus { private func fetchObjects() -> [T] { do { let fileManager = FileManager.default - let filenames = try fileManager.contentsOfDirectory(atPath: self.directoryURL.path) + 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 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 [] } } } -extension CollectionQuery: Sendable {} - // MARK: Sequence 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 c0a2177..148669a 100644 --- a/Sources/Papyrus/Queries/ObjectQuery.swift +++ b/Sources/Papyrus/Queries/ObjectQuery.swift @@ -3,7 +3,7 @@ import Foundation import os /// `ObjectQuery` is a mechanism for querying a single `Papyrus` object. -public struct ObjectQuery { +public struct ObjectQuery: Sendable { private let decoder: JSONDecoder = .init() private let directoryURL: URL private let filename: String @@ -25,7 +25,7 @@ public struct ObjectQuery { /// 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: @@ -37,7 +37,7 @@ public struct ObjectQuery { /// - Returns: A `AsyncThrowingStream` instance. public func observe() -> AsyncThrowingStream, Error> where T: Sendable { do { - let observer = try DirectoryObserver(url: self.directoryURL) + let observer = try DirectoryObserver(url: directoryURL) let object = fetchObject() let observerSequence = observer.observe() .map { fetchObject() } @@ -68,9 +68,9 @@ public struct ObjectQuery { private func fetchObject() -> Result { let fileManager = FileManager.default - let fileURL = self.directoryURL.appendingPathComponent(self.filename) + let fileURL = directoryURL.appendingPathComponent(filename) guard fileManager.fileExists(atPath: fileURL.path) else { - self.logger.info("Cached data not found. url: \(fileURL)") + logger.info("Cached data not found. url: \(fileURL)") return .failure(NotFoundError()) } @@ -79,13 +79,13 @@ public struct ObjectQuery { 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)") + 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)) @@ -93,8 +93,6 @@ public struct ObjectQuery { } } -extension ObjectQuery: Sendable {} - // MARK: Errors extension ObjectQuery { @@ -151,4 +149,5 @@ extension AsyncPairSequence { } } -extension AsyncPairSequence.Iterator: Sendable where Base.AsyncIterator: Sendable, Element: Sendable {} +extension AsyncPairSequence.Iterator: Sendable +where Base.AsyncIterator: Sendable, Element: Sendable {} From f7cca2580b6a347ef3ed7136b61934d9d1ba0d60 Mon Sep 17 00:00:00 2001 From: Red Date: Wed, 23 Apr 2025 12:52:52 +0100 Subject: [PATCH 06/12] remove trailing , for older Swift version --- Package.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index c30f5a0..74886c4 100644 --- a/Package.swift +++ b/Package.swift @@ -21,17 +21,17 @@ let package = Package( name: "Papyrus", dependencies: [ .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), - ], + ] ), .testTarget( name: "Unit", dependencies: ["Papyrus"], - exclude: ["Supporting Files/Unit.xctestplan"], + exclude: ["Supporting Files/Unit.xctestplan"] ), .testTarget( name: "Performance", dependencies: ["Papyrus"], exclude: ["Supporting Files/Performance.xctestplan"] - ), - ], + ) + ] ) From 5cc4cc8e002ef726e3ec342f1e9acab4ae63506c Mon Sep 17 00:00:00 2001 From: Red Date: Wed, 23 Apr 2025 13:58:57 +0100 Subject: [PATCH 07/12] Remove a couple more --- Package.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 74886c4..98290a8 100644 --- a/Package.swift +++ b/Package.swift @@ -13,14 +13,15 @@ let package = Package( products: [ .library( name: "Papyrus", - targets: ["Papyrus"]), + targets: ["Papyrus"] + ) ], dependencies: [.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0")], targets: [ .target( name: "Papyrus", dependencies: [ - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") ] ), .testTarget( From 7757f5ba25b97856336253b82b7615fdcf23f38a Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 5 Jan 2026 10:33:00 +0000 Subject: [PATCH 08/12] style(CollectionQuery): improve code formatting by removing extra whitespace - Removed unnecessary blank lines and trailing whitespace throughout the file - Improved code readability while maintaining functionality --- Sources/Papyrus/Queries/CollectionQuery.swift | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Sources/Papyrus/Queries/CollectionQuery.swift b/Sources/Papyrus/Queries/CollectionQuery.swift index 5d91298..80a7e08 100644 --- a/Sources/Papyrus/Queries/CollectionQuery.swift +++ b/Sources/Papyrus/Queries/CollectionQuery.swift @@ -6,16 +6,16 @@ import os 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 filter: OnFilter? private let logger: Logger private let sort: OnSort? - + // MARK: Initialization - + init( directoryURL: URL, filter: OnFilter? = nil, @@ -26,16 +26,16 @@ public struct CollectionQuery: Sendable where T: Papyrus { 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] { fetchObjects() } - + /// Apply a filter to the query. /// - Parameter onFilter: The filter to be applied. /// - Returns: The query item. @@ -46,7 +46,7 @@ public struct CollectionQuery: Sendable where T: Papyrus { sort: sort ) } - + /// Apply a sort to the query. /// - Parameter onSort: The sort to be applied. /// - Returns: The query item. @@ -57,7 +57,7 @@ public struct CollectionQuery: Sendable where T: Papyrus { sort: onSort ) } - + /// Observe changes to the query. /// - Returns: A `AsyncThrowingStream` instance. public func observe() -> AsyncThrowingStream<[T], Error> where T: Sendable { @@ -71,7 +71,7 @@ public struct CollectionQuery: Sendable where T: Papyrus { .eraseToThrowingStream() } } - + private func fetchObjects() -> [T] { do { let fileManager = FileManager.default @@ -117,7 +117,7 @@ extension Sequence { guard let isIncluded = isIncluded else { return Array(self) } return filter { isIncluded($0) } } - + fileprivate func sorted(by areInIncreasingOrder: ((Element, Element) -> Bool)?) -> [Element] { guard let areInIncreasingOrder = areInIncreasingOrder else { return Array(self) } return sorted { areInIncreasingOrder($0, $1) } From 4e2b696587c53418542a057d589216143d667b65 Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 5 Jan 2026 10:33:12 +0000 Subject: [PATCH 09/12] refactor(structure): improve AsyncPairSequence organization - Made AsyncPairSequence and its Iterator fileprivate - Moved AsyncSequence extension to the bottom of the file - Grouped related components with appropriate MARK comments - Removed unnecessary whitespace throughout the file --- Sources/Papyrus/Queries/ObjectQuery.swift | 43 ++++++++++++----------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/Sources/Papyrus/Queries/ObjectQuery.swift b/Sources/Papyrus/Queries/ObjectQuery.swift index 148669a..71e503f 100644 --- a/Sources/Papyrus/Queries/ObjectQuery.swift +++ b/Sources/Papyrus/Queries/ObjectQuery.swift @@ -8,9 +8,9 @@ public struct ObjectQuery: Sendable { private let directoryURL: URL private let filename: String private let logger: Logger - + // MARK: Initialization - + init( id: ID, directoryURL: URL @@ -19,9 +19,9 @@ public struct ObjectQuery: Sendable { self.directoryURL = directoryURL self.logger = Logger(subsystem: "com.reddavis.PapyrusStore", category: "ObjectQuery") } - + // MARK: API - + /// Executes the query. /// - Returns: The result of the query. public func execute() -> T? { @@ -32,7 +32,7 @@ public struct ObjectQuery: Sendable { return nil } } - + /// Observe changes to the query via an async stream. /// - Returns: A `AsyncThrowingStream` instance. public func observe() -> AsyncThrowingStream, Error> where T: Sendable { @@ -41,13 +41,13 @@ public struct ObjectQuery: Sendable { 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,7 +65,7 @@ public struct ObjectQuery: Sendable { .eraseToThrowingStream() } } - + private func fetchObject() -> Result { let fileManager = FileManager.default let fileURL = directoryURL.appendingPathComponent(filename) @@ -73,7 +73,7 @@ public struct ObjectQuery: Sendable { 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)) @@ -102,14 +102,9 @@ extension ObjectQuery { } } +// MARK: AsyncPairSequence -extension AsyncSequence { - func pair() -> AsyncPairSequence { - AsyncPairSequence(base: self) - } -} - -struct AsyncPairSequence: AsyncSequence { +fileprivate struct AsyncPairSequence: AsyncSequence { typealias AsyncIterator = Iterator typealias Element = (Base.Element, Base.Element) var base: Base @@ -121,10 +116,10 @@ struct AsyncPairSequence: AsyncSequence { extension AsyncPairSequence: Sendable where Base: Sendable, Element: Sendable {} -// MARK: Iterator +// MARK: AsyncPairSequence Iterator extension AsyncPairSequence { - struct Iterator: AsyncIteratorProtocol { + fileprivate struct Iterator: AsyncIteratorProtocol { var base: Base.AsyncIterator var lastValue: Base.Element? @@ -132,17 +127,17 @@ extension AsyncPairSequence { 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) } @@ -151,3 +146,9 @@ extension AsyncPairSequence { extension AsyncPairSequence.Iterator: Sendable where Base.AsyncIterator: Sendable, Element: Sendable {} + +extension AsyncSequence { + fileprivate func pair() -> AsyncPairSequence { + AsyncPairSequence(base: self) + } +} From 6d410890e8dd177eafd81e6b4de83824208c9ce3 Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 5 Jan 2026 10:33:23 +0000 Subject: [PATCH 10/12] chore(deps): update swift-async-algorithms to 1.1.1 - Upgraded swift-async-algorithms package from 1.0.0 to 1.1.1 - Updated swift-collections dependency from 1.1.1 to 1.3.0 - Package.resolved file updated to version 3 format with originHash --- Package.resolved | 11 ++++++----- Package.swift | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Package.resolved b/Package.resolved index 7142500..0b389e9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,13 @@ { + "originHash" : "56d20ae0e9b26ba8fd8e419a8c54d6482d21800a8ce4cf2b6340fe42c6e3185e", "pins" : [ { "identity" : "swift-async-algorithms", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms", "state" : { - "revision" : "da4e36f86544cdf733a40d59b3a2267e3a7bbf36", - "version" : "1.0.0" + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" } }, { @@ -14,10 +15,10 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", - "version" : "1.1.1" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 98290a8..4724d17 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( targets: ["Papyrus"] ) ], - dependencies: [.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0")], + dependencies: [.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.1")], targets: [ .target( name: "Papyrus", From 950c88df8708e36a3202cab623beb0bd70151d22 Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 5 Jan 2026 10:38:12 +0000 Subject: [PATCH 11/12] ci: update GitHub Actions workflow configuration - Changed macOS runner from macOS-13 to macOS-26 --- .github/workflows/ci.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6120009..14fe285 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: + runs-on: macOS-26 + env: DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests run: swift test - \ No newline at end of file From 0c79ec938334ebcaf485118e0c8fe2d8378a81a8 Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 5 Jan 2026 10:41:25 +0000 Subject: [PATCH 12/12] chore(ci): update Xcode version in GitHub workflow - Updated DEVELOPER_DIR path from Xcode 15.0 to Xcode 26.2 for macOS CI runner --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14fe285..8e2b648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: name: Unit Tests runs-on: macOS-26 env: - DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests