Core Data Orphan CareEvent Store Assignment
MeowCare 這次在真機安裝執行時爆掉,錯誤停在 PersistenceController.attachOrphanEvents:
1
Can't reassign an object to a different store once it has been saved
畫面上的情境是:App 啟動後會確保存在一個 default CareSession,並把舊資料裡 session == nil 的 CareEvent 補掛到這個 session。這種舊資料就是 orphan event。
原本想解的問題
MeowCare 有 private store 與 shared store。為了避免新建的 CareEvent 沒有明確 store,早先的想法是在補 session 時順手 assign 到 private store:
1
2
3
4
5
6
for event in orphans {
event.session = session
if let privateStore {
context.assign(event, to: privateStore)
}
}
這段看起來像是在保護資料一致性:event 要掛到 default session,而 default session 屬於 private store,所以 event 也應該指定到 private store。
但這個寫法少了一個關鍵前提:context.assign(_:to:) 不是拿來搬已存檔物件的。
Apple 的 NSManagedObjectContext.assign(_:to:) 說明是:指定 newly inserted object 要存到哪個 persistent store。也就是說,它適合「剛 insert、還沒 save、尚未固定 store」的物件,不適合拿來重新指定已存檔物件。
怎麼發生
舊版留下的 orphan CareEvent 已經存在 SQLite 裡,只是 relationship 還沒有補上:
1
2
3
event.session == nil
event.objectID.isTemporaryID == false
event.objectID.persistentStore != nil
這種 object 已經屬於某個 store。當程式再呼叫:
1
context.assign(event, to: privateStore)
Core Data 會拒絕,因為它不是新 inserted object。真機就會丟出:
1
Can't reassign an object to a different store once it has been saved
所以問題不是 event.session = session,而是「對已存檔 object 重新 assign store」。
最後怎麼改
修正後只在 event 還沒有固定 store 時才 assign:
1
2
3
4
5
6
7
8
for event in orphans {
if event.objectID.isTemporaryID,
event.objectID.persistentStore == nil,
let targetStore = session.objectID.persistentStore ?? privateStore {
context.assign(event, to: targetStore)
}
event.session = session
}
這裡有三個重點。
第一,isTemporaryID 必須是 true。這表示物件還沒有取得 permanent object ID,尚未真正固定到某個 persistent store。
第二,persistentStore == nil。這是再確認物件目前沒有 store 歸屬。
第三,target store 優先取 session.objectID.persistentStore:
1
let targetStore = session.objectID.persistentStore ?? privateStore
因為 relationship 兩端必須在同一個 store。既然 event 要掛到 session,最可靠的 target 是 session 自己所在的 store;只有 session 還沒有 store 時,才 fallback 到 private store。
已存檔 orphan event 的處理就很單純:
1
event.session = session
不要 assign,不要嘗試搬 store。
測試環境也一起修
這次新增測試後,另一個問題浮出來:hosted unit tests 會先啟動 App process。也就是 PersistenceController.shared 可能在測試中被建立。
如果測試仍使用正式 SQLite / CloudKit-aware store,就會有兩個問題:
- 測試資料可能寫進正式 store。
- 測試自己再建立 container 時,可能和正式 store 互相干擾。
所以測試期間也要讓 shared persistence controller 使用 in-memory store:
1
2
3
4
5
6
7
8
9
10
11
static let isRunningForTests: Bool = {
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
}()
static let shouldUseInMemoryStore: Bool = {
isRunningForPreviews || isRunningForTests
}()
static let shared = shouldUseInMemoryStore
? PersistenceController(inMemory: true)
: PersistenceController()
in-memory store 也要有不同 URL identity
MeowCare 有 private/shared 兩個 persistent store description。即使使用 NSInMemoryStoreType,也仍然給它們不同 URL:
1
2
let inMemoryStoreDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("MeowCare-InMemory-\(UUID().uuidString)", isDirectory: true)
1
2
3
4
privateDesc = NSPersistentStoreDescription(
url: inMemoryStoreDirectory.appendingPathComponent("MeowCare-Private.sqlite")
)
privateDesc.type = NSInMemoryStoreType
1
2
3
4
sharedDesc = NSPersistentStoreDescription(
url: inMemoryStoreDirectory.appendingPathComponent("MeowCare-Shared.sqlite")
)
sharedDesc.type = NSInMemoryStoreType
這不是要讓 in-memory store 寫 SQLite。NSInMemoryStoreType 還是記憶體 store。
重點是 Core Data 仍可能用 URL 當 store identity。若 private/shared 都沒有 URL,兩者可能都落到相同 default identity,第二個 store 載入時就可能被視為重複 store。
測試案例
新增測試專門覆蓋「已存檔 orphan event 補 session」:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test("已存檔 orphan CareEvent 補 session 時不重新指定 store")
func savedOrphanCareEventCanAttachDefaultSession() throws {
let controller = PersistenceController.shared
let context = controller.container.viewContext
let event = CareEvent(context: context)
event.id = UUID().uuidString
event.eventTypeRaw = EventType.note.rawValue
event.occurredAt = try date(year: 2026, month: 7, day: 27)
event.updatedAt = event.occurredAt
try context.save()
#expect(event.session == nil)
let session = controller.ensureDefaultSession(in: context)
try context.save()
#expect(event.session === session)
}
這個測試的重點不是建立全新 event,而是先 save(),讓 event 變成「已存檔 orphan」。如果程式仍然一律 assign,這種情境就會重現真機 crash。
記住
這次修正可以整理成幾條規則:
context.assign(_:to:)只用在新 inserted、尚未固定 store 的 object。- 已存檔 orphan object 只補 relationship,不重新指定 store。
- relationship 的 target store 優先跟 session 一致。
- hosted unit tests 會啟動 App process,所以 shared persistence 也要避開正式 store。
- in-memory 多 store 仍要給不同 URL identity。
最後驗證:
MeowCareTests通過。- Debug 模擬器建置通過。
- Release archive 通過。
- 真機不再因 orphan
CareEvent補 session 而 crash。