Skip to content

Commit 47736c9

Browse files
committed
Add observation-aware NSModelActor initialization
1 parent fdfb27f commit 47736c9

33 files changed

Lines changed: 4299 additions & 3605 deletions

Docs/NSModelActorGuide.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ the macro adds:
7474
- `nonisolated let modelExecutor: NSModelObjectContextExecutor`
7575
- `nonisolated let modelContainer: NSPersistentContainer`
7676
- `init(container: NSPersistentContainer)` unless disabled
77+
- `init(observationDomain: CDEObservationDomain)` with Swift compiler 6.2+ on iOS 17+ /
78+
macOS 14+ platform families, unless disabled
7779
- `NSModelActor` conformance
7880

7981
The generated initializer always uses:
@@ -84,6 +86,38 @@ let context = container.newBackgroundContext()
8486

8587
That is an intentional behavior contract in this package.
8688

89+
The Observation-aware initializer is available when CDE's MainActor Observation runtime is available
90+
(Swift compiler 6.2+ plus the platform availability below):
91+
92+
```swift
93+
@MainActor
94+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
95+
init(observationDomain: CDEObservationDomain)
96+
```
97+
98+
It creates and registers the actor's background context through the domain:
99+
100+
```swift
101+
let container = observationDomain.modelContainer
102+
let context = container.newBackgroundContext()
103+
observationDomain.registerChangeProducer(context: context)
104+
modelExecutor = NSModelObjectContextExecutor(context: context)
105+
modelContainer = container
106+
```
107+
108+
Use this initializer when the actor owns background writes and you want direct
109+
`modelContext.save()` calls to produce property-level Observation metadata. The generated actor keeps
110+
the domain and producer registration alive for its own context. Inline construction is valid:
111+
112+
```swift
113+
let writer = ItemStore(observationDomain: CDEObservationDomain(container: container))
114+
```
115+
116+
Releasing the actor releases its retained domain/registration, and that teardown is safe even if the
117+
actor's final release happens off the MainActor. If the retained domain is explicitly invalidated
118+
while the actor lives, the actor remains a valid Core Data actor; its context is no longer a
119+
registered Observation producer, so normal unregistered-context fallback rules apply.
120+
87121
## What `@NSMainModelActor` Generates
88122

89123
For a class declaration:
@@ -156,6 +190,23 @@ create a new scheduling layer.
156190
For production writes, prefer dedicated mutation methods on the actor or class instead of exposing
157191
raw context access everywhere.
158192

193+
### `saveObservedChanges()`
194+
195+
For actors created with `init(observationDomain:)`, the generated no-argument
196+
`saveObservedChanges()` saves the actor context through the retained Observation setup:
197+
198+
```swift
199+
try await saveObservedChanges()
200+
```
201+
202+
When the actor is not bound to an observation domain, this method falls back to a plain
203+
`modelContext.save()` and logs a one-time runtime warning that no CDE Observation metadata will be
204+
produced.
205+
206+
This no-argument overload is generated with the initializer set. When using
207+
`@NSModelActor(disableGenerateInit: true)`, define your own save wrapper if you need stored-domain
208+
fallback behavior.
209+
159210
## Custom Initializers
160211

161212
If you need extra stored properties or a custom context setup, disable initializer generation:
@@ -203,6 +254,36 @@ For `@NSMainModelActor`, that means:
203254

204255
- `modelContainer`
205256

257+
### Custom Observation-aware initializers
258+
259+
When a custom `@NSModelActor` initializer should keep the same Observation producer behavior as the
260+
generated overload, retain the domain and producer registration alongside the context:
261+
262+
```swift
263+
@NSModelActor(disableGenerateInit: true)
264+
actor ItemStore {
265+
private let observationDomain: CDEObservationDomain?
266+
private let observationProducerRegistration: CDEObservationProducerRegistration?
267+
268+
init(observationDomain: CDEObservationDomain) {
269+
let container = observationDomain.modelContainer
270+
let context = container.newBackgroundContext()
271+
self.observationDomain = observationDomain
272+
observationProducerRegistration = observationDomain.registerChangeProducer(context: context)
273+
modelExecutor = NSModelObjectContextExecutor(context: context)
274+
modelContainer = container
275+
}
276+
277+
deinit {
278+
observationProducerRegistration?.invalidate()
279+
}
280+
}
281+
```
282+
283+
For custom actors, keep your own save wrapper if you need optional fallback behavior. The existing
284+
`saveObservedChanges(in:)` remains useful for plain actor contexts that do not retain a producer
285+
registration.
286+
206287
## Testing Patterns
207288

208289
### Use `NSPersistentContainer.makeTest`

Docs/ObservationGuide.md

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ invalidation.
1515

1616
## Availability
1717

18-
MainActor Observation requires Swift Observation platform support:
18+
MainActor Observation requires Swift compiler support for CDE's Observation runtime plus Swift
19+
Observation platform support:
1920

21+
- Swift compiler 6.2+
2022
- iOS 17+
2123
- macOS 14+
2224
- tvOS 17+
@@ -93,7 +95,7 @@ saved change for `title`, Swift Observation invalidates that reader.
9395
Observation consumption is MainActor-bound and centered on `container.viewContext`.
9496

9597
- Read observed model objects from MainActor UI code.
96-
- Keep `CDEObservationDomain` on MainActor.
98+
- Create and explicitly invalidate `CDEObservationDomain` on MainActor.
9799
- Treat background contexts as metadata producers only. They should not publish Observation changes
98100
directly.
99101
- If an opt-in model is used without a retained domain for its `viewContext`, reads can still compile,
@@ -126,7 +128,8 @@ Use the strongest producer route that matches the context you own.
126128
| Source | Public API | Precision | Notes |
127129
|---|---|---|---|
128130
| `viewContext` save | `try viewContext.save()` | property-level | A retained domain instruments its own `viewContext`; `NSMainModelActor.saveObservedChanges(in:)` is symmetry sugar. |
129-
| `@NSModelActor` background save | `try await saveObservedChanges(in: observation)` | property-level | Stages changed keys before save without suspending between staging and commit. |
131+
| Observation-aware `@NSModelActor` background save | create the actor with `init(observationDomain:)`, then call `try modelContext.save()` or `try await saveObservedChanges()` | property-level after successful save | The generated initializer retains the domain and registration for its actor context. |
132+
| Plain `@NSModelActor` background save | `try await saveObservedChanges(in: observation)` | property-level | Stages changed keys before save without suspending between staging and commit. |
130133
| Arbitrary context wrapper | `try await observation.saveObservedChanges(in: context)` | property-level | Preferred when thrown-save cleanup matters; the wrapper rolls back its staged token and the context on failure. |
131134
| Registered ordinary context | `observation.registerChangeProducer(context:)`, then plain `context.save()` | property-level after successful save | If a direct save throws, call `rollback()`, `reset()`, or invalidate the registration to clear staged notification state. |
132135
| Convenience background context | `observation.newObservedBackgroundContext()` | property-level after successful save | Equivalent to `container.newBackgroundContext()` plus producer registration. |
@@ -136,7 +139,31 @@ Use the strongest producer route that matches the context you own.
136139

137140
## Background Actor Save
138141

139-
Prefer `NSModelActor.saveObservedChanges(in:)` for actor-owned background writes:
142+
When the actor can be created on MainActor from the retained observation domain, prefer the generated
143+
Observation-aware initializer:
144+
145+
```swift
146+
@NSModelActor
147+
actor ItemWriter {
148+
func rename(
149+
id: NSManagedObjectID,
150+
to title: String
151+
) throws {
152+
guard let item = self[id, as: Item.self] else { return }
153+
item.title = title
154+
try modelContext.save()
155+
}
156+
}
157+
158+
let writer = ItemWriter(observationDomain: CDEObservationDomain(container: container))
159+
```
160+
161+
The generated actor retains the domain and producer registration for its actor context. Inline
162+
construction is supported: releasing the actor releases its retained domain/registration, and teardown
163+
is safe even when the actor's final release happens off the MainActor.
164+
165+
For an actor created with the plain `init(container:)`, use `NSModelActor.saveObservedChanges(in:)`
166+
for actor-owned background writes that need property-level metadata:
140167

141168
```swift
142169
@NSModelActor
@@ -153,9 +180,11 @@ actor ItemWriter {
153180
}
154181
```
155182

156-
Calling `modelContext.save()` still saves Core Data correctly, but it bypasses CDE's precise metadata
157-
staging. The domain can only fall back to object-level invalidation when a later merge supplies object
158-
IDs.
183+
Calling `modelContext.save()` from a plain, unregistered actor context still saves Core Data
184+
correctly, but it bypasses CDE's precise metadata staging. The domain can only fall back to
185+
object-level invalidation when a later merge supplies object IDs. If that actor uses the generated
186+
no-argument `saveObservedChanges()` from the plain `init(container:)` path, CDE logs a one-time
187+
warning and performs the same plain save.
159188

160189
## Registered Ordinary Contexts
161190

@@ -283,7 +312,7 @@ userInfo-key summaries are diagnostic details and may change between releases.
283312
Precision Across Store Re-merges).
284313
- Generated setters do not provide immediate unsaved refresh.
285314
- To-many relationship setters are still not generated; use the generated relationship helper methods.
286-
- Keep all UI reads and domain lifecycle operations on MainActor.
315+
- Keep all UI reads and manual domain create/invalidate calls on MainActor.
287316

288317
## Related Guides
289318

Sources/CoreDataEvolution/CoreDataEvolution.docc/CoreDataEvolution.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,15 @@ CoreDataEvolution allows you to create actors with custom executors tied to Core
3131
### MainActor Observation for Persistent Models
3232

3333
`@PersistentModel(observation: .mainActor)` opts a generated `NSManagedObject` model into Swift
34-
Observation on iOS 17+ / macOS 14+ platform families. A retained `CDEObservationDomain` activates
35-
MainActor routing for a container's `viewContext`, so SwiftUI can read generated Core Data accessors
36-
directly and refresh after saved changes or merges.
34+
Observation with a Swift 6.2+ compiler on iOS 17+ / macOS 14+ platform families. A retained
35+
`CDEObservationDomain` activates MainActor routing for a container's `viewContext`, so SwiftUI can
36+
read generated Core Data accessors directly and refresh after saved changes or merges.
37+
38+
Background actors generated by `@NSModelActor` can also be created with
39+
`init(observationDomain:)` on those platform families. That initializer creates a registered
40+
background context and retains the domain setup so direct actor `modelContext.save()` calls can
41+
publish property-level Observation metadata. Inline domain construction is supported; releasing the
42+
actor releases its retained domain setup safely.
3743

3844
## Basic Usage
3945

@@ -124,6 +130,7 @@ import CoreDataEvolution
124130
- Swift 6.0
125131

126132
> Important: The custom executor uses a compatible `UnownedJob` serial-executor path to support the minimum deployment targets.
133+
> MainActor Observation additionally requires a Swift 6.2+ compiler and iOS 17+ / macOS 14+ platform families.
127134
128135
## Acknowledgments
129136

Sources/CoreDataEvolution/Macros.swift

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,20 @@ public enum RelationshipDeleteRule: String, Sendable, Codable {
9292
/// - stores an `NSPersistentContainer`
9393
/// - creates a background `NSManagedObjectContext`
9494
/// - exposes `modelExecutor`
95-
/// - optionally synthesizes `init(container:)`
95+
/// - optionally synthesizes `init(container:)` and the Observation-aware
96+
/// `init(observationDomain:)` on supported OS versions
97+
/// - when initializer generation is enabled, synthesizes `saveObservedChanges()` for actors bound
98+
/// to an observation domain
9699
///
97100
/// Use this when Core Data work should run off the main actor.
98101
///
99102
/// - Parameter disableGenerateInit: When `true`, the macro does not synthesize
100-
/// `init(container:)` and your type must initialize the generated stored properties itself.
101-
@attached(member, names: named(modelExecutor), named(modelContainer), named(init))
103+
/// generated initializers and your type must initialize the generated stored properties itself.
104+
@attached(
105+
member,
106+
names: named(modelExecutor), named(modelContainer), named(init), named(saveObservedChanges),
107+
named(deinit), named(__cdeObservationDomain), named(__cdeObservationProducerRegistration)
108+
)
102109
@attached(extension, conformances: NSModelActor)
103110
public macro NSModelActor(disableGenerateInit: Bool = false) =
104111
#externalMacro(module: "CoreDataEvolutionMacros", type: "NSModelActorMacro")
@@ -230,18 +237,32 @@ public macro _CDObserved(
230237
/// properties for every to-many relationship using the underlying Objective-C collection count.
231238
/// - Parameter observation: Optional Observation code generation mode. The default keeps existing
232239
/// generated output unchanged.
233-
@attached(memberAttribute)
234-
@attached(member, names: arbitrary, named(__cdRuntimeEntitySchema))
235-
@attached(
236-
extension,
237-
conformances: PersistentEntity, CDRuntimeSchemaProviding, CDEObservable,
238-
CDEObservationFieldMapProviding, CDEObservationInvalidationDispatching
239-
)
240-
public macro PersistentModel(
241-
generateInit: Bool = false,
242-
generateToManyCount: Bool = true,
243-
observation: PersistentModelObservationMode = .none
244-
) = #externalMacro(module: "CoreDataEvolutionMacros", type: "PersistentModelMacro")
240+
#if compiler(>=6.2)
241+
@attached(memberAttribute)
242+
@attached(member, names: arbitrary, named(__cdRuntimeEntitySchema))
243+
@attached(
244+
extension,
245+
conformances: PersistentEntity, CDRuntimeSchemaProviding, CDEObservable,
246+
CDEObservationFieldMapProviding, CDEObservationInvalidationDispatching
247+
)
248+
public macro PersistentModel(
249+
generateInit: Bool = false,
250+
generateToManyCount: Bool = true,
251+
observation: PersistentModelObservationMode = .none
252+
) = #externalMacro(module: "CoreDataEvolutionMacros", type: "PersistentModelMacro")
253+
#else
254+
@attached(memberAttribute)
255+
@attached(member, names: arbitrary, named(__cdRuntimeEntitySchema))
256+
@attached(
257+
extension,
258+
conformances: PersistentEntity, CDRuntimeSchemaProviding
259+
)
260+
public macro PersistentModel(
261+
generateInit: Bool = false,
262+
generateToManyCount: Bool = true,
263+
observation: PersistentModelObservationMode = .none
264+
) = #externalMacro(module: "CoreDataEvolutionMacros", type: "PersistentModelMacro")
265+
#endif
245266

246267
/// Declares metadata for a persisted attribute.
247268
///

Sources/CoreDataEvolution/ModelActorSupport.swift

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,25 +39,57 @@ func withModelContext<T: Sendable>(
3939
try action(context, container)
4040
}
4141

42-
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
43-
func collectChangedObservationFieldSets(
44-
from objects: Set<NSManagedObject>
45-
) -> [NSManagedObjectID: CDEObservationFieldSet] {
46-
objects.reduce(into: [:]) { result, object in
47-
guard object.objectID.isTemporaryID == false else {
48-
return
42+
#if compiler(>=6.2)
43+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
44+
func collectChangedObservationFieldSets(
45+
from objects: Set<NSManagedObject>
46+
) -> [NSManagedObjectID: CDEObservationFieldSet] {
47+
objects.reduce(into: [:]) { result, object in
48+
guard object.objectID.isTemporaryID == false else {
49+
return
50+
}
51+
guard let modelType = type(of: object) as? any CDEObservationFieldMapProviding.Type else {
52+
return
53+
}
54+
55+
let fieldSet = modelType.__cdObservationFieldSet(
56+
forCoreDataKeys: object.changedValues().keys
57+
)
58+
guard fieldSet.isEmpty == false else {
59+
return
60+
}
61+
62+
result[object.objectID] = fieldSet
4963
}
50-
guard let modelType = type(of: object) as? any CDEObservationFieldMapProviding.Type else {
51-
return
64+
}
65+
66+
private final class CDEModelActorObservationFallbackLogState: @unchecked Sendable {
67+
private let lock = NSLock()
68+
private var didLog = false
69+
70+
func shouldLog() -> Bool {
71+
lock.withLock {
72+
guard didLog == false else {
73+
return false
74+
}
75+
didLog = true
76+
return true
77+
}
5278
}
79+
}
5380

54-
let fieldSet = modelType.__cdObservationFieldSet(
55-
forCoreDataKeys: object.changedValues().keys
56-
)
57-
guard fieldSet.isEmpty == false else {
81+
private let cdeModelActorObservationFallbackLogState =
82+
CDEModelActorObservationFallbackLogState()
83+
84+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
85+
public func _cdeLogUnboundModelActorObservationSave() {
86+
guard cdeModelActorObservationFallbackLogState.shouldLog() else {
5887
return
5988
}
60-
61-
result[object.objectID] = fieldSet
89+
NSLog(
90+
"CoreDataEvolution Observation warning: saveObservedChanges() was called on an "
91+
+ "@NSModelActor instance that was not created with init(observationDomain:). "
92+
+ "Falling back to modelContext.save(); no CDE Observation metadata will be produced."
93+
)
6294
}
63-
}
95+
#endif

Sources/CoreDataEvolution/NSMainModelActor.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,16 @@ extension NSMainModelActor {
5050
}
5151
}
5252

53-
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
54-
extension NSMainModelActor {
55-
/// Saves `viewContext` changes through the active observation domain.
56-
///
57-
/// `CDEObservationDomain` instruments `viewContext` directly, so this wrapper is symmetry sugar for
58-
/// the main-actor path rather than a separate correctness hook.
59-
public func saveObservedChanges(in observation: CDEObservationDomain) throws {
60-
_ = observation
61-
try modelContext.save()
53+
#if compiler(>=6.2)
54+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
55+
extension NSMainModelActor {
56+
/// Saves `viewContext` changes through the active observation domain.
57+
///
58+
/// `CDEObservationDomain` instruments `viewContext` directly, so this wrapper is symmetry sugar for
59+
/// the main-actor path rather than a separate correctness hook.
60+
public func saveObservedChanges(in observation: CDEObservationDomain) throws {
61+
_ = observation
62+
try modelContext.save()
63+
}
6264
}
63-
}
65+
#endif

0 commit comments

Comments
 (0)