From 73fc1cc07ed37be9006a005ababb74fb0d1a81a4 Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Wed, 11 Feb 2026 07:33:48 -0700 Subject: [PATCH 1/2] Upgrade to Swift 6, add cross-platform CI, add visionOS support - Bump swift-tools-version from 5.6 to 6.0 - Add visionOS(.v1) to supported platforms - Add @Sendable annotations for Swift 6 strict concurrency - Add @unchecked Sendable conformance to ConsumingObservableObject - Conditionalize Combine/ObservableObject usage for cross-platform support - Add DataLoading: Sendable requirement - Replace SwiftUI.Color with cross-platform TestColor in tests - Add Sendable conformance to test types for Swift 6 - Add Ubuntu and Windows CI workflows - Update macOS CI workflow with Xcode 16 setup - Update all actions/checkout from v3 to v4 - Add CI status badges, license badge, and version badge to README - Add requirements section to README Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docc.yml | 2 +- .github/workflows/macOS.yml | 10 ++--- .github/workflows/ubuntu.yml | 20 ++++++++++ .github/workflows/windows.yml | 21 +++++++++++ Package.swift | 11 ++---- README.md | 16 ++++++++ .../DataStore/ConsumingObservableObject.swift | 2 +- Sources/DataStore/DataStore.swift | 12 ++++-- Sources/DataStore/Protocols/DataLoading.swift | 2 +- Sources/DataStore/Protocols/DataStoring.swift | 2 +- Tests/DataStoreTests/DataStoreTests.swift | 5 +-- Tests/DataStoreTests/TestObjects.swift | 37 +++++++++++++------ 12 files changed, 107 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/ubuntu.yml create mode 100644 .github/workflows/windows.yml diff --git a/.github/workflows/docc.yml b/.github/workflows/docc.yml index f8f6077..9c1b405 100644 --- a/.github/workflows/docc.yml +++ b/.github/workflows/docc.yml @@ -18,7 +18,7 @@ jobs: runs-on: macos-12 steps: - name: git checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: docbuild run: > xcodebuild docbuild -scheme DataStore \ diff --git a/.github/workflows/macOS.yml b/.github/workflows/macOS.yml index 7353c39..97e8c2d 100644 --- a/.github/workflows/macOS.yml +++ b/.github/workflows/macOS.yml @@ -2,16 +2,16 @@ name: macOS on: push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] + branches: ["**"] jobs: build: runs-on: macos-latest - steps: - - uses: actions/checkout@v3 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.0 + - uses: actions/checkout@v4 - name: Build run: swift build -v - name: Run tests diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml new file mode 100644 index 0000000..2294bd8 --- /dev/null +++ b/.github/workflows/ubuntu.yml @@ -0,0 +1,20 @@ +name: Ubuntu + +on: + push: + branches: ["**"] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Set up Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: '6.1.0' + - uses: actions/checkout@v4 + - name: Build for release + run: swift build -v -c release + - name: Test + run: swift test -v diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..00a2cfd --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,21 @@ +name: Windows + +on: + push: + branches: ["**"] + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Swift 6.1 + uses: compnerd/gha-setup-swift@main + with: + branch: swift-6.1-release + tag: 6.1-RELEASE + + - run: swift --version + - run: swift build + - run: swift test diff --git a/Package.swift b/Package.swift index 23c1194..f7251b2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.6 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -9,22 +9,19 @@ let package = Package( .macOS(.v10_15), .iOS(.v13), .watchOS(.v6), - .tvOS(.v13) + .tvOS(.v13), + .visionOS(.v1) ], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "DataStore", targets: ["DataStore"] ) ], dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/0xLeif/Cache", from: "2.0.0") + .package(url: "https://github.com/0xLeif/Cache", from: "2.0.0") ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "DataStore", dependencies: [ diff --git a/README.md b/README.md index c12ded9..44f678e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,22 @@ # DataStore +[![macOS Build](https://img.shields.io/github/actions/workflow/status/0xLeif/DataStore/macOS.yml?label=macOS&branch=main)](https://github.com/0xLeif/DataStore/actions/workflows/macOS.yml) +[![Ubuntu Build](https://img.shields.io/github/actions/workflow/status/0xLeif/DataStore/ubuntu.yml?label=Ubuntu&branch=main)](https://github.com/0xLeif/DataStore/actions/workflows/ubuntu.yml) +[![Windows Build](https://img.shields.io/github/actions/workflow/status/0xLeif/DataStore/windows.yml?label=Windows&branch=main)](https://github.com/0xLeif/DataStore/actions/workflows/windows.yml) +[![License](https://img.shields.io/github/license/0xLeif/DataStore)](https://github.com/0xLeif/DataStore/blob/main/LICENSE) +[![Version](https://img.shields.io/github/v/release/0xLeif/DataStore)](https://github.com/0xLeif/DataStore/releases) + *An extendable data storage solution with built-in caching capabilities.* +## Requirements + +- **iOS**: 13.0+ +- **watchOS**: 6.0+ +- **macOS**: 10.15+ +- **tvOS**: 13.0+ +- **visionOS**: 1.0+ +- **Swift**: 6.0+ + ## What is DataStore? `DataStore` is a generic data storage and caching library for Swift. It provides a convenient way to load, cache, and store data in your apps. The `DataStore` class, by default, utilizes a cache to improve data loading performance. However, it can be subclassed to support other data storage mechanisms while still benefiting from the caching capabilities. @@ -13,6 +28,7 @@ - **Flexible Data Retrieval**: Access loaded data via the `fetch()` method or fetch data based on specific criteria using closure-based filtering. - **Data Storage**: Store new data objects into the `DataStore` for later retrieval. - **Data Deletion**: Remove previously stored data objects from the `DataStore`. +- **Cross-Platform**: Supports macOS, iOS, watchOS, tvOS, visionOS, Ubuntu, and Windows. ## Installation diff --git a/Sources/DataStore/ConsumingObservableObject.swift b/Sources/DataStore/ConsumingObservableObject.swift index a0f18cc..898337e 100644 --- a/Sources/DataStore/ConsumingObservableObject.swift +++ b/Sources/DataStore/ConsumingObservableObject.swift @@ -2,7 +2,7 @@ import Combine /// An `ObservableObject` that can consume and observe changes in other `ObservableObject` instances. -open class ConsumingObservableObject: ObservableObject { +open class ConsumingObservableObject: ObservableObject, @unchecked Sendable { private var bag: Set = Set() deinit { diff --git a/Sources/DataStore/DataStore.swift b/Sources/DataStore/DataStore.swift index 24843d0..137d8af 100644 --- a/Sources/DataStore/DataStore.swift +++ b/Sources/DataStore/DataStore.swift @@ -16,7 +16,11 @@ import Cache - SeeAlso: `Identifiable` - SeeAlso: `Cache` */ +#if canImport(Combine) open class DataStore: ConsumingObservableObject, DataStoring where DataLoader.DeviceData.To == DataLoader.DeviceData.StoredValue { +#else +open class DataStore: DataStoring, @unchecked Sendable where DataLoader.DeviceData.To == DataLoader.DeviceData.StoredValue { +#endif /// A typealias that represents the type of data loaded by the DataLoader. public typealias LoadedData = DataLoader.LoadedData @@ -47,10 +51,12 @@ open class DataStore: ConsumingObservableObject, DataSt ) { self.cache = Cache(initialValues: initalValues) self.loader = loader - + + #if canImport(Combine) super.init() - + consume(object: cache) + #endif } /** @@ -108,7 +114,7 @@ open class DataStore: ConsumingObservableObject, DataSt - Note: This method filters the fetched data using the given filter closure and returns the filtered results. */ - open func fetch(where filter: (DeviceData) -> Bool) async -> [DeviceData] { + open func fetch(where filter: @Sendable (DeviceData) -> Bool) async -> [DeviceData] { let allValues = await fetch() let filteredValues = allValues.filter(filter) diff --git a/Sources/DataStore/Protocols/DataLoading.swift b/Sources/DataStore/Protocols/DataLoading.swift index 47821cb..75072e7 100644 --- a/Sources/DataStore/Protocols/DataLoading.swift +++ b/Sources/DataStore/Protocols/DataLoading.swift @@ -1,5 +1,5 @@ /// A protocol for loading data asynchronously and adapting it to a desired format using the `Adaptable` protocol. -public protocol DataLoading { +public protocol DataLoading: Sendable { /// The type of loaded data, which should conform to the `Identifiable` protocol. associatedtype LoadedData: Identifiable diff --git a/Sources/DataStore/Protocols/DataStoring.swift b/Sources/DataStore/Protocols/DataStoring.swift index 1ebf31a..7e76ef0 100644 --- a/Sources/DataStore/Protocols/DataStoring.swift +++ b/Sources/DataStore/Protocols/DataStoring.swift @@ -21,7 +21,7 @@ public protocol DataStoring { /// Fetches stored `DeviceData` objects that satisfy the given filter predicate. /// - Parameter where: A closure that takes a `DeviceData` object and returns a Boolean value indicating whether the object should be included in the result. /// - Returns: An array of fetched device-specific data that pass the filter. - func fetch(where filter: (DeviceData) -> Bool) async -> [DeviceData] + func fetch(where filter: @Sendable (DeviceData) -> Bool) async -> [DeviceData] /// Fetches a single stored `DeviceData` object based on its identifier. /// - Parameter id: The identifier of the data to fetch. diff --git a/Tests/DataStoreTests/DataStoreTests.swift b/Tests/DataStoreTests/DataStoreTests.swift index 19cbd1b..39ec688 100644 --- a/Tests/DataStoreTests/DataStoreTests.swift +++ b/Tests/DataStoreTests/DataStoreTests.swift @@ -1,4 +1,3 @@ -import SwiftUI import XCTest @testable import DataStore @@ -31,7 +30,7 @@ final class DataStoreTests: XCTestCase { try await store.load() let values = await store.fetch { data in - data.color == Color(red: 0, green: 1, blue: 0) + data.color == TestColor(red: 0, green: 1, blue: 0) } XCTAssertEqual(values.count, 1) @@ -46,7 +45,7 @@ final class DataStoreTests: XCTestCase { let expectedID = UUID().uuidString let expectedUserName = "test" - let expectedColor = Color.green + let expectedColor = TestColor.green let expectedEnumValue = TestDeviceData.DeviceEnum.weirdCaseExample try await store.store( diff --git a/Tests/DataStoreTests/TestObjects.swift b/Tests/DataStoreTests/TestObjects.swift index 3553463..b5f26ca 100644 --- a/Tests/DataStoreTests/TestObjects.swift +++ b/Tests/DataStoreTests/TestObjects.swift @@ -1,16 +1,31 @@ import DataStore + +#if canImport(SwiftUI) import SwiftUI +#endif + +// MARK: - TestColor + +/// A cross-platform color representation for testing. +struct TestColor: Equatable, Sendable { + let red: Double + let green: Double + let blue: Double + + static let clear = TestColor(red: 0, green: 0, blue: 0) + static let green = TestColor(red: 0, green: 1, blue: 0) +} // MARK: - TestLoadedData -struct TestLoadedData: Identifiable { - struct LoadedColor { +struct TestLoadedData: Identifiable, Sendable { + struct LoadedColor: Sendable { let red: Double let green: Double let blue: Double } - enum LoadedEnum: String { + enum LoadedEnum: String, Sendable { case WeirdCaseExample case inconsistent_example } @@ -23,8 +38,8 @@ struct TestLoadedData: Identifiable { // MARK: - TestDeviceData -struct TestDeviceData: OnDeviceData { - enum DeviceEnum: String, Adaptable { +struct TestDeviceData: OnDeviceData, Sendable { + enum DeviceEnum: String, Adaptable, Sendable { case weirdCaseExample case inconsistentExample @@ -40,10 +55,10 @@ struct TestDeviceData: OnDeviceData { let id: String var userName: String - var color: Color + var color: TestColor var enumValue: DeviceEnum - init(id: String, userName: String, color: Color, enumValue: DeviceEnum) { + init(id: String, userName: String, color: TestColor, enumValue: DeviceEnum) { self.id = id self.userName = userName self.color = color @@ -54,7 +69,7 @@ struct TestDeviceData: OnDeviceData { self.init( id: from.id, userName: from.user_name, - color: Color( + color: TestColor( red: from.color.red, green: from.color.green, blue: from.color.blue @@ -75,10 +90,10 @@ struct TestDeviceData: OnDeviceData { // MARK: - StoredData -struct TestStoredData: StorableData { +struct TestStoredData: StorableData, Sendable { let id: String let userName: String - let color: Color + let color: TestColor let enumValue: TestDeviceData.DeviceEnum init(from: TestDeviceData) { @@ -91,7 +106,7 @@ struct TestStoredData: StorableData { // MARK: - TestDataLoader -class TestDataLoader: DataLoading { +final class TestDataLoader: DataLoading, Sendable { typealias LoadedData = TestLoadedData typealias DeviceData = TestDeviceData From 4ce0f31de3ce5908b9cafd3cf35ebaa250e17711 Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Sun, 15 Feb 2026 17:46:25 -0700 Subject: [PATCH 2/2] fix: add NSLock to protect bag in ConsumingObservableObject The class is marked @unchecked Sendable but had no synchronization on the mutable bag property, creating a data race when consume() is called from multiple threads. Add NSLock around all bag accesses. Co-Authored-By: Claude Opus 4.6 --- Sources/DataStore/ConsumingObservableObject.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/DataStore/ConsumingObservableObject.swift b/Sources/DataStore/ConsumingObservableObject.swift index 898337e..f534c67 100644 --- a/Sources/DataStore/ConsumingObservableObject.swift +++ b/Sources/DataStore/ConsumingObservableObject.swift @@ -1,12 +1,16 @@ #if canImport(Combine) import Combine +import Foundation /// An `ObservableObject` that can consume and observe changes in other `ObservableObject` instances. open class ConsumingObservableObject: ObservableObject, @unchecked Sendable { + private let bagLock = NSLock() private var bag: Set = Set() deinit { + bagLock.lock() bag.removeAll() + bagLock.unlock() } public init() { } @@ -17,6 +21,8 @@ open class ConsumingObservableObject: ObservableObject, @unchecked Sendable { public func consume( object: Object ) where ObjectWillChangePublisher == ObservableObjectPublisher { + bagLock.lock() + defer { bagLock.unlock() } bag.insert( object.objectWillChange.sink( receiveCompletion: { _ in },