diff --git a/Sources/CoreModel/InMemoryStorage.swift b/Sources/CoreModel/InMemoryStorage.swift index 2153dc1..c61fe7c 100644 --- a/Sources/CoreModel/InMemoryStorage.swift +++ b/Sources/CoreModel/InMemoryStorage.swift @@ -6,10 +6,6 @@ // Copyright © 2026 PureSwift. All rights reserved. // -#if !hasFeature(Embedded) -import Synchronization -#endif - /// Shared in-memory backing store. /// /// Holds the objects and registered functions for an ``InMemoryModelStorage`` and, @@ -17,18 +13,14 @@ import Synchronization /// reference type, a store and a view context that share the same instance observe /// the exact same data. /// -/// The type is thread-safe: the mutable state lives inside a `Mutex`, so the -/// actor-isolated ``InMemoryModelStorage`` and the main-actor ``InMemoryViewContext`` -/// can operate on the same instance concurrently. Under Embedded Swift the mutex is -/// elided: with a concurrency runtime the backing is only ever touched from within its +/// The type is thread-safe: the mutable state lives behind a ``Locked`` (a `Mutex` +/// where the runtime has one, `NSLock` on older Darwin), so the sendable +/// ``InMemoryModelStorage`` and the main-actor ``InMemoryViewContext`` can operate +/// on the same instance concurrently. Under Embedded Swift the lock is elided: +/// with a concurrency runtime the backing is only ever touched from within its /// owning actor, and without one (e.g. bare-metal ARM, where ``ModelStorage`` /// itself is unavailable) this synchronous store is the storage API, used /// directly from the single-threaded main loop. -/// -/// - Note: On Apple platforms this type is gated on the availability of -/// `Synchronization.Mutex`, which is not back-deployed. The rest of `CoreModel` -/// keeps the package's lower deployment targets. -@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) internal final class InMemoryStorage { /// The schema entities are validated against. @@ -45,7 +37,7 @@ internal final class InMemoryStorage { #if hasFeature(Embedded) private var state = State() #else - private let state = Mutex(State()) + private let state = Locked(State()) #endif public init(model: Model) { @@ -56,7 +48,7 @@ internal final class InMemoryStorage { #if hasFeature(Embedded) return try body(&state) #else - return try state.withLock { (state) throws(E) in + return try state.withValue { (state) throws(E) in try body(&state) } #endif @@ -220,11 +212,9 @@ internal final class InMemoryStorage { #if hasFeature(Embedded) // - Note: Safe because the backing is confined to the owning actor, or to the // single-threaded main loop on targets without a concurrency runtime. -@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) extension InMemoryStorage: @unchecked Sendable {} #else // - Note: Checked: `model` is an immutable `Sendable` value and every piece of -// mutable state lives inside the `Mutex`. -@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) +// mutable state lives inside the ``Locked``. extension InMemoryStorage: Sendable {} #endif diff --git a/Sources/CoreModel/InMemoryStore.swift b/Sources/CoreModel/InMemoryStore.swift index d1fa836..bda223e 100644 --- a/Sources/CoreModel/InMemoryStore.swift +++ b/Sources/CoreModel/InMemoryStore.swift @@ -22,10 +22,6 @@ /// /// On platforms that support it, ``viewContext`` returns a synchronous, /// main-actor ``InMemoryViewContext`` backed by the same data. -/// -/// - Note: Inherits ``InMemoryStorage``'s Apple-platform availability, which is -/// gated on `Synchronization.Mutex`. -@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) public actor InMemoryModelStorage { internal let backing: InMemoryStorage @@ -94,7 +90,6 @@ public actor InMemoryModelStorage { // MARK: - ViewContext #if !hasFeature(Embedded) -@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) public extension InMemoryModelStorage { /// A synchronous, main-actor view context backed by the same data as this store. @@ -121,7 +116,6 @@ public extension InMemoryModelStorage { // `CoreModelError` to `any Error` is disallowed (`#EmbeddedRestrictions`). // Embedded consumers call the store's methods directly; they provide the // same API with typed throws. -@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) extension InMemoryModelStorage: ModelStorage {} #endif diff --git a/Sources/CoreModel/InMemoryViewContext.swift b/Sources/CoreModel/InMemoryViewContext.swift index 65fefa3..d7933be 100644 --- a/Sources/CoreModel/InMemoryViewContext.swift +++ b/Sources/CoreModel/InMemoryViewContext.swift @@ -22,10 +22,6 @@ /// /// Useful for SwiftUI previews, unit tests, and lightweight main-thread caches /// where the `async` ``InMemoryModelStorage`` would be inconvenient. -/// -/// - Note: Inherits ``InMemoryStorage``'s Apple-platform availability, which is -/// gated on `Synchronization.Mutex`. -@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @MainActor public final class InMemoryViewContext { @@ -94,7 +90,6 @@ public final class InMemoryViewContext { // MARK: - ViewContext -@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) extension InMemoryViewContext: ViewContext {} #endif diff --git a/Sources/CoreModel/Locked.swift b/Sources/CoreModel/Locked.swift new file mode 100644 index 0000000..1b26930 --- /dev/null +++ b/Sources/CoreModel/Locked.swift @@ -0,0 +1,124 @@ +// +// Locked.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/21/26. +// Copyright © 2026 PureSwift. All rights reserved. +// + +// - Note: Unavailable under Embedded Swift — the types that would use it there +// (``InMemoryStorage``) elide the lock entirely, since state is confined to the +// owning actor or the single-threaded main loop. +#if !hasFeature(Embedded) + +#if canImport(Darwin) +import Foundation +#endif +import Synchronization + +/// Mutable state behind a lock. +/// +/// Lets a class expose `Sendable` thread-safe access to mutable state without +/// raising the package's deployment targets to where `Synchronization.Mutex` +/// became available. +/// +/// Backed by `Mutex` wherever the runtime has it (non-Darwin, and Darwin at or above +/// iOS 18 / macOS 15), falling back to `NSLock` on the older Darwin releases this +/// package still deploys to. +internal final class Locked: Sendable { + + // `@available` isn't allowed on enum cases with associated values, and naming the + // Mutex-backed class in a payload would require it — so that payload is stored + // erased and downcast behind the same availability check that constructed it. + private enum Storage: @unchecked Sendable { + case mutex(AnyObject) + #if canImport(Darwin) + case nsLock(NSLockStorage) + #endif + } + + private let storage: Storage + + public init(_ value: Value) { + #if canImport(Darwin) + if #available(iOS 18, macOS 15, watchOS 11, tvOS 18, visionOS 2, *) { + self.storage = .mutex(MutexStorage(value)) + } else { + self.storage = .nsLock(NSLockStorage(value)) + } + #else + self.storage = .mutex(MutexStorage(value)) + #endif + } + + public var value: Value { + withValue { $0 } + } + + @discardableResult + public func withValue( + _ body: (inout Value) throws(Failure) -> Result + ) throws(Failure) -> Result { + switch storage { + case .mutex(let storage): + guard #available(iOS 18, macOS 15, watchOS 11, tvOS 18, visionOS 2, *) else { + fatalError("Mutex storage constructed on an OS without the Synchronization runtime") + } + return try unsafeDowncast(storage, to: MutexStorage.self).withValue(body) + #if canImport(Darwin) + case .nsLock(let storage): + return try storage.withValue(body) + #endif + } + } +} + +// MARK: - Storage + +@available(iOS 18, macOS 15, watchOS 11, tvOS 18, visionOS 2, *) +private final class MutexStorage: Sendable { + + // `Mutex` requires `sending` values in and out; `Locked` predates that and promises + // only what `NSLock` did. The wrapper opts out of the transfer checking so both + // backends expose identical semantics. + private struct UncheckedSendable: @unchecked Sendable { + var wrapped: Wrapped + } + + private let mutex: Mutex> + + init(_ value: Value) { + self.mutex = Mutex(UncheckedSendable(wrapped: value)) + } + + func withValue( + _ body: (inout Value) throws(Failure) -> Result + ) throws(Failure) -> Result { + try mutex.withLock { (state: inout UncheckedSendable) throws(Failure) -> UncheckedSendable in + UncheckedSendable(wrapped: try body(&state.wrapped)) + }.wrapped + } +} + +#if canImport(Darwin) +private final class NSLockStorage: @unchecked Sendable { + + private let lock = NSLock() + + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func withValue( + _ body: (inout Value) throws(Failure) -> Result + ) throws(Failure) -> Result { + lock.lock() + defer { lock.unlock() } + return try body(&value) + } +} +#endif + +#endif diff --git a/Tests/CoreModelTests/InMemoryStoreTests.swift b/Tests/CoreModelTests/InMemoryStoreTests.swift index 941545f..d5ed7a0 100644 --- a/Tests/CoreModelTests/InMemoryStoreTests.swift +++ b/Tests/CoreModelTests/InMemoryStoreTests.swift @@ -16,7 +16,6 @@ import Testing EntityDescription(entity: Event.self) ]) - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func insertAndFetch() async throws { let store = InMemoryModelStorage(model: Self.model) let person = Person(name: "Alice", age: 30) @@ -28,7 +27,6 @@ import Testing #expect(missing == nil) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func update() async throws { let store = InMemoryModelStorage(model: Self.model) var person = Person(name: "Alice", age: 30) @@ -41,7 +39,6 @@ import Testing #expect(count == 1) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func batchInsert() async throws { let store = InMemoryModelStorage(model: Self.model) let people = (1...10).map { Person(name: "Person \($0)", age: UInt(20 + $0)) } @@ -50,7 +47,6 @@ import Testing #expect(count == 10) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func fetchRequest() async throws { let store = InMemoryModelStorage(model: Self.model) let people = (1...5).map { Person(name: "Person \($0)", age: UInt(20 + $0)) } @@ -81,7 +77,6 @@ import Testing #expect(count == 2) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func fetchID() async throws { let store = InMemoryModelStorage(model: Self.model) let person = Person(name: "Alice", age: 30) @@ -90,7 +85,6 @@ import Testing #expect(ids == [ObjectID(person.id)]) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func delete() async throws { let store = InMemoryModelStorage(model: Self.model) let people = (1...3).map { Person(name: "Person \($0)", age: UInt(20 + $0)) } @@ -104,7 +98,6 @@ import Testing #expect(count == 0) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func relationshipPredicate() async throws { let store = InMemoryModelStorage(model: Self.model) let event = Event(name: "WWDC", date: Date()) @@ -118,7 +111,6 @@ import Testing #expect(attendees == [attendee]) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func customFunction() async throws { let store = InMemoryModelStorage(model: Self.model) let stringLength = DatabaseFunction(name: "LENGTH", argumentCount: 1) { arguments in @@ -144,7 +136,6 @@ import Testing #expect(longNames.map { $0.name } == ["Alexandra"]) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func modelValidation() async throws { let store = InMemoryModelStorage(model: Self.model) let person = Person(name: "Alice", age: 30) diff --git a/Tests/CoreModelTests/InMemoryViewContextTests.swift b/Tests/CoreModelTests/InMemoryViewContextTests.swift index 44a2630..b4c48f7 100644 --- a/Tests/CoreModelTests/InMemoryViewContextTests.swift +++ b/Tests/CoreModelTests/InMemoryViewContextTests.swift @@ -17,7 +17,6 @@ import Testing EntityDescription(entity: Event.self) ]) - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func insertAndFetch() throws { let context = InMemoryViewContext(model: Self.model) let person = Person(name: "Alice", age: 30) @@ -29,7 +28,6 @@ import Testing #expect(missing == nil) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func update() throws { let context = InMemoryViewContext(model: Self.model) var person = Person(name: "Alice", age: 30) @@ -42,7 +40,6 @@ import Testing #expect(count == 1) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func batchInsert() throws { let context = InMemoryViewContext(model: Self.model) let people = (1...10).map { Person(name: "Person \($0)", age: UInt(20 + $0)) } @@ -51,7 +48,6 @@ import Testing #expect(count == 10) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func fetchRequest() throws { let context = InMemoryViewContext(model: Self.model) let people = (1...5).map { Person(name: "Person \($0)", age: UInt(20 + $0)) } @@ -82,7 +78,6 @@ import Testing #expect(count == 2) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func fetchID() throws { let context = InMemoryViewContext(model: Self.model) let person = Person(name: "Alice", age: 30) @@ -91,7 +86,6 @@ import Testing #expect(ids == [ObjectID(person.id)]) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func delete() throws { let context = InMemoryViewContext(model: Self.model) let people = (1...3).map { Person(name: "Person \($0)", age: UInt(20 + $0)) } @@ -105,7 +99,6 @@ import Testing #expect(count == 0) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func relationshipPredicate() throws { let context = InMemoryViewContext(model: Self.model) let event = Event(name: "WWDC", date: Date()) @@ -119,7 +112,6 @@ import Testing #expect(attendees == [attendee]) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func customFunction() throws { let context = InMemoryViewContext(model: Self.model) let stringLength = DatabaseFunction(name: "LENGTH", argumentCount: 1) { arguments in @@ -145,7 +137,6 @@ import Testing #expect(longNames.map { $0.name } == ["Alexandra"]) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func sharedDataWithStore() async throws { let store = InMemoryModelStorage(model: Self.model) let context = store.viewContext @@ -167,7 +158,6 @@ import Testing #expect(try context.count(FetchRequest(entity: Person.entityName)) == 1) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func concurrentAccessWithStore() async throws { let store = InMemoryModelStorage(model: Self.model) let context = store.viewContext @@ -190,7 +180,6 @@ import Testing #expect(count == 100) } - @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func modelValidation() throws { let context = InMemoryViewContext(model: Self.model) let person = Person(name: "Alice", age: 30)