Skip to content

Latest commit

 

History

History
484 lines (341 loc) · 13.5 KB

File metadata and controls

484 lines (341 loc) · 13.5 KB

NSModelActor Guide

@NSModelActor and @NSMainModelActor bring SwiftData-style isolation patterns to Core Data without requiring SwiftData itself.

This guide is written for library users. It explains:

  • when to use each macro
  • what code the macros generate
  • how to structure your actor or main-actor type
  • how to use the convenience APIs
  • how to test these types safely
  • which constraints are intentional in the current implementation

CoreDataEvolution re-exports CoreData, so normal use sites usually only need:

import CoreDataEvolution

You do not normally need a separate import CoreData.

Choose the Right Macro

Use @NSModelActor when the type should own a private Core Data context and serialize its work through an actor.

import CoreDataEvolution

@NSModelActor
actor ItemStore {
  func createItem(timestamp: Date) throws -> NSManagedObjectID {
    let item = Item(context: modelContext)
    item.timestamp = timestamp
    try modelContext.save()
    return item.objectID
  }
}

Use @NSMainModelActor when the type should always operate on viewContext from the main actor.

import CoreDataEvolution

@MainActor
@NSMainModelActor
final class ItemViewModel {
  func createItem(timestamp: Date) throws {
    let item = Item(context: modelContext)
    item.timestamp = timestamp
    try modelContext.save()
  }
}

Rule of thumb:

  • @NSModelActor: background work, isolated writes, actor-based APIs
  • @NSMainModelActor: UI-facing orchestration that must stay on the main actor

What @NSModelActor Generates

For an actor declaration:

@NSModelActor
actor ItemStore {}

the macro adds:

  • nonisolated let modelExecutor: NSModelObjectContextExecutor
  • nonisolated let modelContainer: NSPersistentContainer
  • init(container: NSPersistentContainer) unless disabled
  • init(observationDomain: CDEObservationDomain) with Swift compiler 6.2+ on iOS 17+ / macOS 14+ platform families, unless disabled
  • NSModelActor conformance

The generated initializer always uses:

let context = container.newBackgroundContext()

That is an intentional behavior contract in this package.

The Observation-aware initializer is available when CDE's MainActor Observation runtime is available (Swift compiler 6.2+ plus the platform availability below):

@MainActor
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
init(observationDomain: CDEObservationDomain)

It creates a background context from the domain's container and registers that context with the domain:

let container = observationDomain.modelContainer
let context = container.newBackgroundContext()
observationDomain.registerChangeProducer(context: context)
modelExecutor = NSModelObjectContextExecutor(context: context)
modelContainer = container

Use this initializer when the actor owns background writes and you want saves from that actor context to produce property-level Observation metadata. The generated actor keeps the domain and producer registration alive for its own context. Prefer the generated saveObservedChanges() wrapper for observed update paths; successful direct modelContext.save() calls from the same registered context can also participate in precise routing, but saveObservedChanges() is the documented actor-facing Observation save API. Inline construction is valid:

let writer = ItemStore(observationDomain: CDEObservationDomain(container: container))

Releasing the actor releases its retained domain/registration, and that teardown is safe even if the actor's final release happens off the MainActor. If the retained domain is explicitly invalidated while the actor lives, the actor remains a valid Core Data actor; its context is no longer a registered Observation producer, so normal unregistered-context fallback rules apply.

What @NSMainModelActor Generates

For a class declaration:

@MainActor
@NSMainModelActor
final class ItemViewModel {}

the macro adds:

  • let modelContainer: NSPersistentContainer
  • init(modelContainer: NSPersistentContainer) unless disabled
  • NSMainModelActor conformance

modelContext is not stored directly. The protocol extension always resolves it as:

modelContainer.viewContext

Generated Convenience APIs

Both protocols expose a small convenience surface.

modelContext

The context that should be used by your methods.

  • NSModelActor: the context wrapped by modelExecutor
  • NSMainModelActor: viewContext

Typed subscript

Load an object by NSManagedObjectID and expected type:

guard let item = self[itemID, as: Item.self] else {
  throw StoreError.itemNotFound
}

This is useful when the caller only has an object ID and the actor should rehydrate the object inside its own isolation domain.

withContext

Two overloads are available on both protocols:

try await handler.withContext { context in
  // inspect or query the actor's context
}

try await handler.withContext { context, container in
  // inspect the context and also access the container
}

These APIs are primarily for:

  • tests
  • debugging
  • verification queries that do not deserve a dedicated production API

They are synchronous closures executed inside the type's existing isolation boundary. They do not create a new scheduling layer.

For production writes, prefer dedicated mutation methods on the actor or class instead of exposing raw context access everywhere.

saveObservedChanges()

For actors created with init(observationDomain:), the generated no-argument saveObservedChanges() saves the actor context through the retained Observation setup:

try await saveObservedChanges()

This method is the actor-facing API for property-level Observation invalidation of updated objects. In a bound actor, the retained producer registration snapshots updated fields during save and the domain routes only the affected observable key paths after a successful save. If save throws, CDE clears its staged Observation metadata and rethrows without rolling back the actor context. Insert and delete operations do not need property-level metadata; use normal Core Data saves for those paths.

When the actor is not bound to an observation domain, this method falls back to a plain modelContext.save() and logs a one-time runtime warning that no CDE Observation metadata will be produced.

This no-argument overload is generated with the initializer set. When using @NSModelActor(disableGenerateInit: true), define your own save wrapper if you need a no-argument domain-bound Observation save API.

Custom Initializers

If you need extra stored properties or a custom context setup, disable initializer generation:

@NSModelActor(disableGenerateInit: true)
actor ItemStore {
  let viewName: String

  init(container: NSPersistentContainer, viewName: String) {
    modelContainer = container
    self.viewName = viewName

    let context = container.newBackgroundContext()
    context.name = viewName
    modelExecutor = .init(context: context)
  }
}

For @NSMainModelActor:

@MainActor
@NSMainModelActor(disableGenerateInit: true)
final class ItemViewModel {
  let screenName: String

  init(modelContainer: NSPersistentContainer, screenName: String) {
    self.modelContainer = modelContainer
    self.screenName = screenName
  }
}

When you disable the generated initializer, you are responsible for assigning every generated stored property correctly.

For @NSModelActor, that means:

  • modelContainer
  • modelExecutor

For @NSMainModelActor, that means:

  • modelContainer

Custom Observation-aware initializers

When a custom @NSModelActor initializer should keep the same Observation producer behavior as the generated overload, retain the domain and producer registration alongside the context:

@NSModelActor(disableGenerateInit: true)
actor ItemStore {
  private let observationDomain: CDEObservationDomain?
  private let observationProducerRegistration: CDEObservationProducerRegistration?

  init(observationDomain: CDEObservationDomain) {
    let container = observationDomain.modelContainer
    let context = container.newBackgroundContext()
    self.observationDomain = observationDomain
    observationProducerRegistration = observationDomain.registerChangeProducer(context: context)
    modelExecutor = NSModelObjectContextExecutor(context: context)
    modelContainer = container
  }

  deinit {
    observationProducerRegistration?.invalidate()
  }
}

For custom actors, keep your own save wrapper if you need a no-argument domain-bound Observation save API. The existing saveObservedChanges(in:) remains useful for plain actor contexts that do not retain a producer registration.

Testing Patterns

Use NSPersistentContainer.makeTest

For schema-backed tests, prefer:

let container = try NSPersistentContainer.makeTest(model: MySchema.objectModel)

This helper intentionally:

  • uses an on-disk SQLite store and clears stale files before loading
  • deletes stale sidecar files before loading
  • serializes container creation and loadPersistentStores

Treat this helper as a one-shot test container by default:

  • the default name comes from the call site (#fileID + #function)
  • that is usually the right choice for one container per test method
  • if one test method needs multiple containers, pass distinct testName values

This SQLite-backed approach is intentional:

  • it avoids the shared-state and deadlock risks of /dev/null
  • it exercises a more realistic SQLite + WAL setup than shared in-memory stores
  • in heavily parallel suites, it is often more robust than shared in-memory approaches

Do not switch back to /dev/null or a shared in-memory URL.

Use withContext for assertions

In tests, the recommended pattern is:

  1. call the actor's public API
  2. verify state with withContext

Example:

let stack = try TestStack()
let handler = DataHandler(container: stack.container, viewName: "test")

_ = try await handler.createItem(timestamp: .now)

let count = try await handler.withContext { context in
  let request = Item.fetchRequest()
  return try context.fetch(request).count
}

#expect(count == 1)

This keeps the mutation path realistic while still allowing direct assertions.

Runtime-model tests

If you are testing macro-generated runtime schema instead of .xcdatamodeld, use:

let container = try NSPersistentContainer.makeRuntimeTest(modelTypes: Item.self, Tag.self)

That path is intended for test and debug workflows only. It is not a replacement for production Core Data model versioning.

Visibility Rules

The macros mirror the attached type's visibility for generated members.

One special case exists:

  • if the attached type is private or fileprivate
  • generated witness members use fileprivate

That is required so the synthesized conformance extension can still see the witnesses.

Required Source Rules

@NSModelActor

  • attach it to an actor
  • if you disable init generation, assign modelContainer and modelExecutor yourself
  • the generated default initializer always uses newBackgroundContext()

@NSMainModelActor

  • attach it to a class
  • mark the type @MainActor
  • if you disable init generation, assign modelContainer yourself
  • the type always uses viewContext

@MainActor remains a source-level requirement. The macro does not silently rewrite the attached type's isolation attributes for you.

Common Mistakes

Forgetting @MainActor on @NSMainModelActor

Bad:

@NSMainModelActor
final class ItemViewModel {}

Good:

@MainActor
@NSMainModelActor
final class ItemViewModel {}

The macro does not currently enforce @MainActor itself. This is still a source-level rule you should follow rather than something the macro silently rewrites on your behalf.

Disabling init generation without assigning generated members

Bad:

@NSModelActor(disableGenerateInit: true)
actor ItemStore {
  init(container: NSPersistentContainer) {}
}

Good:

@NSModelActor(disableGenerateInit: true)
actor ItemStore {
  init(container: NSPersistentContainer) {
    modelContainer = container
    modelExecutor = .init(context: container.newBackgroundContext())
  }
}

Treating withContext as the main production API

withContext is intentionally low-level. Use it for tests and debugging, not as a replacement for clear domain methods.

Prefer:

try await store.updateTimestamp(id: itemID, to: .now)

over exposing every operation through raw context closures.

Recommended Structure

For background actors:

  • keep public methods small and task-oriented
  • load objects inside the actor by object ID
  • save explicitly after mutations
  • use withContext only for assertions or debugging

For main-actor handlers:

  • keep UI coordination on the main actor
  • reserve heavy write flows for background actors when appropriate
  • use the same NSPersistentContainer when the UI and background actors need to cooperate

Current Boundaries

These are intentional in the current design:

  • @NSModelActor uses newBackgroundContext() by default
  • @NSMainModelActor uses viewContext
  • withContext is synchronous within the current isolation domain
  • the package does not try to hide raw Core Data save semantics
  • test helpers prioritize store isolation and parallel-suite stability over in-memory convenience

Relationship to the README

This guide is the detailed reference for the actor macros and testing helpers.

The README can stay shorter and focus on:

  • what the library does
  • why the actor macros exist
  • a minimal usage example
  • links to this guide for the full workflow