Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 8 additions & 18 deletions Sources/CoreModel/InMemoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,21 @@
// 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,
/// optionally, one or more ``InMemoryViewContext`` values. Because the backing is a
/// 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.
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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
6 changes: 0 additions & 6 deletions Sources/CoreModel/InMemoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
5 changes: 0 additions & 5 deletions Sources/CoreModel/InMemoryViewContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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
124 changes: 124 additions & 0 deletions Sources/CoreModel/Locked.swift
Original file line number Diff line number Diff line change
@@ -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<Value>: 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<Value>)
#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<Result, Failure: Error>(
_ 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<Value>.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<Value>: 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<Wrapped>: @unchecked Sendable {
var wrapped: Wrapped
}

private let mutex: Mutex<UncheckedSendable<Value>>

init(_ value: Value) {
self.mutex = Mutex(UncheckedSendable(wrapped: value))
}

func withValue<Result, Failure: Error>(
_ body: (inout Value) throws(Failure) -> Result
) throws(Failure) -> Result {
try mutex.withLock { (state: inout UncheckedSendable<Value>) throws(Failure) -> UncheckedSendable<Result> in
UncheckedSendable(wrapped: try body(&state.wrapped))
}.wrapped
}
}

#if canImport(Darwin)
private final class NSLockStorage<Value>: @unchecked Sendable {

private let lock = NSLock()

private var value: Value

init(_ value: Value) {
self.value = value
}

func withValue<Result, Failure: Error>(
_ body: (inout Value) throws(Failure) -> Result
) throws(Failure) -> Result {
lock.lock()
defer { lock.unlock() }
return try body(&value)
}
}
#endif

#endif
9 changes: 0 additions & 9 deletions Tests/CoreModelTests/InMemoryStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)) }
Expand All @@ -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)) }
Expand Down Expand Up @@ -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)
Expand All @@ -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)) }
Expand All @@ -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())
Expand All @@ -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
Expand All @@ -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)
Expand Down
11 changes: 0 additions & 11 deletions Tests/CoreModelTests/InMemoryViewContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)) }
Expand All @@ -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)) }
Expand Down Expand Up @@ -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)
Expand All @@ -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)) }
Expand All @@ -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())
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading