From 2ab46048bf291ff70c4c2f8fbce0ffbb897a4630 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 17 Sep 2026 15:42:58 -0400 Subject: [PATCH] Observe remote SwiftData history on iOS 27 Replace the Core Data remote-change notification bridge with a SwiftData HistoryObserver while preserving the external-author classifier, startup catch-up, coalescing, and store change streams. Exercise the production source with temporary on-disk containers and update its module contracts. --- TODOs.md | 6 +- Where/WhereCore/AGENTS.md | 6 +- Where/WhereCore/README.md | 9 +- .../Persistence/StoreRemoteChangeSource.swift | 101 ++++------ .../Sources/Persistence/SwiftDataStore.swift | 52 ++--- .../Tests/StoreRemoteChangeSourceTests.swift | 186 +++++++++--------- 6 files changed, 161 insertions(+), 199 deletions(-) diff --git a/TODOs.md b/TODOs.md index 2ba3cd6ce..52fc96a01 100644 --- a/TODOs.md +++ b/TODOs.md @@ -91,9 +91,6 @@ inbox rather than here. # Open issues -## PX (Exploratory) -- feat: Update the deployment target to iOS 27 — this lets us use `HistoryObserver` for CloudKit/SwiftData instead of the notification. Spans every target's minimum OS (`Package.swift`, `Project.swift`), so it sits here rather than in `Where/TODOs.md`. (human) - ## P0s (Must do) - fix(Bumper) [quick-win]: `where.gregorian_calendar` matches only an explicit `Calendar` base, so it enforces nothing. It filters `MemberAccessExprSyntax` on `base?.trimmedDescription == "Calendar"` (`.bumper/Sources/WhereProjectRules.swift:124-125`, rule at `:117-137`, `severity: .error` at `:119`), which catches a spelled-out `Calendar.current` but not the implicit-member form (`calendar: Calendar = .current`, `startOfDay(in: .current)`) — and after the Gregorian call-site pass (`fe99dde`) the implicit form is the only one left in the tree: **still 12 sites** (re-counted 2026-08-30), four of them shipped production paths and eight in DEBUG snapshot/preview fixtures (enumerated in the `CalendarDay.displayDate` P1 in [`Where/TODOs.md`](Where/TODOs.md)). CI still hard-gates the lint and is green, which confirms the rule reports none of them — the `architecture` job at `.github/workflows/ci.yml:72-73` reaches `bumper config`/`test`/`lint` through `test:253-261`. **Why it has survived six audits:** the rule's own mutation test only ever feeds it a spelled-out `Calendar.current` (`.bumper/Tests/WhereProjectRulesTests.swift:154-196`, both rejection fixtures at `:170` and `:177`), so the test passes for the same reason the rule fails — fix both together, and add an implicit-member case to the test first. Also match a no-base `MemberAccessExprSyntax` whose contextual type is `Calendar`, or add a lexical `.current` check scoped to calendar parameters and arguments. A rule that reads as enforced but enforces nothing is worse than a documented convention, because it stops anyone from looking. (audit 2026-07-26; re-verified 2026-09-06 — still 12 implicit sites, none reported) @@ -137,6 +134,9 @@ inbox rather than here. - Trap: **a shared DerivedData reports false negatives here.** Two separate runs reported "no frameworks produced" from an incremental build that had not re-resolved the package graph. Spike this into a fresh `-derivedDataPath` or it will lie to you. # Completed issues +## PX (Exploratory) +- feat: Update the deployment target to iOS 27 — completed in the iOS 27 minimum and stacked HistoryObserver PRs. `Package.swift` and `Project.swift` now require iOS 27.0; WhereCore observes SwiftData history without the Core Data remote-change notification bridge. (human; closed 2026-09-17) + ## P0s (Must do) - docs(Bumper) [quick-win]: Correct `.bumper/RULES.md:101` and `:143`, which claimed three calendar violations and some preview-coverage violations were "left visible during this bootstrap". Neither existed — the lint gate was green, and `52f0136` closed the preview ones. Closed by deleting both paragraphs after re-confirming a clean `swift run bumper lint .` ("No architecture violations found") and a green `swift run bumper test .`; re-add the calendar paragraph only if the widened rule genuinely finds drift. (audit 2026-07-26, closed 2026-07-27) diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 7c45f440d..8a88305ed 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -104,9 +104,9 @@ internal shape. - **Writes await their side effects.** `DayJournal` commits. Then it awaits the reminder reconcile + widget publish in sequence. A reader on the next `changes()` ping never observes a half-applied write. -- **Filter persistent-store remote-change notifications by the Where store URL - and the store instance's transaction author.** Never let Periscope or Where's - own local saves enter `remoteChanges()`. Guard: `StoreRemoteChangeSourceTests`. +- **Observe remote history through Where's `ModelContainer` and exclude this + store instance's transaction author.** Never let Periscope or Where's own + local saves enter `remoteChanges()`. Guard: `StoreRemoteChangeSourceTests`. - **Route new writes through the existing reconciliation seams.** Use `DayJournal.reconcileAfterDayDataChange()` or its widget-less subset `reconcileIssueState()`; cross-collaborator hooks take a single closure diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 8b7ca2a13..b3520f140 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -26,10 +26,11 @@ one it belongs to rather than to a god-object: data generation use `perform(expectedDataGenerationID:)`, and multi-table reads use `readSnapshot { … }` so a Reset or Replace cannot split one operation across generations. A persistent-history boundary invalidates any external commit - crossing a snapshot even when its remote-change notification arrives later. - `changes()` emits once per local commit and external import for the Where store - URL, excluding other stores such as Periscope. `remoteChanges()` uses - persistent-history transaction authors to emit only the external-import subset, + crossing a snapshot even when its history observer reports the change later. + `changes()` emits once per local commit and external import for the Where model + container, excluding other stores such as Periscope. `remoteChanges()` uses + SwiftData's `HistoryObserver` and persistent-history transaction authors to + emit only the external-import subset, so headless notifications and widgets rebuild without duplicating local work. `SwiftDataStore.make(storage:)` opens an explicitly selected CloudKit, local-only, or in-memory store. On-disk modes carry their App Group identifier; diff --git a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift index 307a1e3a7..f3da0fc8f 100644 --- a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift +++ b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift @@ -1,5 +1,5 @@ -import CoreData import Foundation +import Observation import PeriscopeCore import SwiftData @@ -10,13 +10,12 @@ import SwiftData /// path, regardless of who wrote). /// /// The seam exists so the whole remote-change path is exercisable off-device: -/// production wires `PersistentStoreRemoteChangeSource` (a real Core Data -/// notification observer), tests wire `ScriptedStoreRemoteChangeSource` and call -/// `yield()`. Only Apple's contract — that the CloudKit mirror actually posts -/// the notification on import — stays untested here. +/// production wires `HistoryObserverRemoteChangeSource`, tests wire +/// `ScriptedStoreRemoteChangeSource` and call `yield()`. Only Apple's contract +/// that a CloudKit import reaches SwiftData's history observer stays untested here. /// /// Class-only (`AnyObject`) because every implementation owns long-lived state -/// (a notification token, an `AsyncStream.Continuation`) that can't be +/// (an observation task, an `AsyncStream.Continuation`) that can't be /// value-copied. Mirrors `LocationSource`. protocol StoreRemoteChangeSource: AnyObject, Sendable { /// Emits once per imported remote change. A bare `Void`: the store re-pings @@ -26,67 +25,49 @@ protocol StoreRemoteChangeSource: AnyObject, Sendable { var remoteChanges: AsyncStream { get } } -/// Production `StoreRemoteChangeSource`: bridges Core Data's -/// `.NSPersistentStoreRemoteChange` notification into an `AsyncStream`. That -/// notification fires both when the CloudKit mirror -/// (`NSPersistentCloudKitContainer`) imports records synced from another device -/// and when a sibling process writes to a shared App Group store (the Where -/// share extension saving evidence) — persistent-history tracking is on for -/// on-disk stores. Observing it and re-reading is Apple's documented way to -/// react to remote SwiftData/CloudKit and cross-process changes. +/// Production `StoreRemoteChangeSource`: observes SwiftData history for an +/// on-disk container. It covers CloudKit imports and sibling App Group writes. /// -/// Despite its name, Core Data posts the notification for this process's own -/// writes too when persistent-history notifications are enabled. The source -/// therefore stamps local `ModelContext` saves with a per-store author and -/// consults SwiftData history before forwarding only external transactions. +/// `HistoryObserver` filters included authors, but cannot express all authors +/// except this store instance's author. Classify the history rows it reports +/// before forwarding an external-only change. /// -/// SwiftData doesn't expose its underlying `NSPersistentStoreCoordinator`, so -/// notifications are scoped by Apple's `NSPersistentStoreURLKey` instead. The -/// app also owns a separate Periscope store; its commits must not masquerade as -/// changes to Where's domain data and trigger a refresh/logging feedback loop. -final class PersistentStoreRemoteChangeSource: NSObject, StoreRemoteChangeSource, - @unchecked Sendable -{ +/// The observer is scoped to this `ModelContainer`, so Periscope commits cannot +/// trigger a Where refresh. A history catch-up closes the setup interval between +/// the classifier's baseline and observation startup. +final class HistoryObserverRemoteChangeSource: StoreRemoteChangeSource { private static let logger = WhereLog.root(SwiftDataStoreLog.self) let remoteChanges: AsyncStream - private let center: NotificationCenter - private let observedStoreURL: URL + private let observer: HistoryObserver private let continuation: AsyncStream.Continuation private let candidateContinuation: AsyncStream.Continuation private let classificationTask: Task + private let observationTask: Task convenience init( modelContainer: ModelContainer, - storeURL: URL, localTransactionAuthor: String, - center: NotificationCenter, ) throws { try self.init( modelContainer: modelContainer, - storeURL: storeURL, localTransactionAuthor: localTransactionAuthor, - center: center, afterHistoryBaseline: {}, ) } #if DEBUG /// Test seam for committing a transaction in the narrow interval after the history - /// baseline is captured but before notification observation begins. + /// baseline is captured but before history observation begins. convenience init( modelContainer: ModelContainer, - storeURL: URL, localTransactionAuthor: String, - center: NotificationCenter, testingAfterHistoryBaseline: () throws -> Void, ) throws { try self.init( modelContainer: modelContainer, - storeURL: storeURL, localTransactionAuthor: localTransactionAuthor, - center: center, afterHistoryBaseline: testingAfterHistoryBaseline, ) } @@ -94,13 +75,9 @@ final class PersistentStoreRemoteChangeSource: NSObject, StoreRemoteChangeSource private init( modelContainer: ModelContainer, - storeURL: URL, localTransactionAuthor: String, - center: NotificationCenter, afterHistoryBaseline: () throws -> Void, ) throws { - self.center = center - observedStoreURL = storeURL.standardizedFileURL let (stream, continuation) = AsyncStream.makeStream( of: Void.self, bufferingPolicy: .bufferingNewest(1), @@ -117,10 +94,12 @@ final class PersistentStoreRemoteChangeSource: NSObject, StoreRemoteChangeSource localTransactionAuthor: localTransactionAuthor, ) try afterHistoryBaseline() + let observer = try HistoryObserver(modelContainer: modelContainer) + self.observer = observer classificationTask = Task { for await _ in candidates { do { - if try await classifier.hasExternalTransactionsSinceLastNotification() { + if try await classifier.hasExternalTransactionsSinceLastCheck() { continuation.yield() } } catch { @@ -134,39 +113,31 @@ final class PersistentStoreRemoteChangeSource: NSObject, StoreRemoteChangeSource } } } - super.init() - center.addObserver( - self, - selector: #selector(persistentStoreDidChange(_:)), - name: .NSPersistentStoreRemoteChange, - object: nil, - ) - // The history baseline necessarily predates target/selector registration. Classify once - // after registration to close that gap: a transaction committed there already missed its - // notification, but its durable history row is now visible to this catch-up pass. + let initialCounter = observer.eventCounter + observationTask = Task { + var previousCounter = initialCounter + for await counter in Observations({ observer.eventCounter }) { + guard counter != previousCounter else { continue } + previousCounter = counter + candidateContinuation.yield() + } + } + // A transaction between the history baseline and observation startup + // may have no event left to deliver. Its durable history row is visible + // to this catch-up pass. candidateContinuation.yield() } deinit { - center.removeObserver(self) + observationTask.cancel() candidateContinuation.finish() classificationTask.cancel() continuation.finish() } - - @objc private func persistentStoreDidChange(_ notification: Notification) { - guard let changedStoreURL = notification.userInfo?[NSPersistentStoreURLKey] as? URL, - changedStoreURL.standardizedFileURL == observedStoreURL - else { return } - candidateContinuation.yield() - } } -/// Classifies persistent-store notifications through SwiftData history. Core -/// Data posts its so-called remote notification for every write when the option -/// is enabled, including this process's own saves; transaction authors are the -/// durable distinction between those local commits and CloudKit/sibling-process -/// imports. +/// Classifies observed SwiftData history by transaction author so local saves +/// do not duplicate the focused reconciliation their callers already await. private actor PersistentHistoryRemoteChangeClassifier { private let context: ModelContext private let localTransactionAuthor: String @@ -186,7 +157,7 @@ private actor PersistentHistoryRemoteChangeClassifier { lastTransactionID = try context.fetchHistory(latest).first?.transactionIdentifier ?? .min } - func hasExternalTransactionsSinceLastNotification() throws -> Bool { + func hasExternalTransactionsSinceLastCheck() throws -> Bool { let previousTransactionID = lastTransactionID let descriptor = HistoryDescriptor( predicate: #Predicate { transaction in diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 295f65547..d9f8b74d7 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -394,9 +394,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// Whether a store of this mode can receive writes from outside this /// process — a sibling App Group process (the share extension) for any - /// on-disk store, or a CloudKit sync from another device — surfaced as - /// `.NSPersistentStoreRemoteChange`. In-memory stores have no shared - /// container and no other writers, so there's nothing to observe. + /// on-disk store, or a CloudKit sync from another device. In-memory + /// stores have no shared container and no other writers to observe. var observesRemoteChanges: Bool { switch self { case .inMemory: false @@ -454,11 +453,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { case let .localOnly(appGroupIdentifier), let .cloudKit(appGroupIdentifier): .identifier(appGroupIdentifier) } - // CloudKit mode backs the container with `NSPersistentCloudKitContainer`, - // which enables persistent-history tracking and posts - // `.NSPersistentStoreRemoteChange` on remote import — no extra knobs - // needed (and SwiftData exposes none). `make` observes that notification - // via `PersistentStoreRemoteChangeSource`. + // CloudKit and sibling App Group writes are observed through SwiftData + // history by `HistoryObserverRemoteChangeSource` after opening the store. return ModelConfiguration( schema: schema, isStoredInMemoryOnly: storage == .inMemory, @@ -519,24 +515,16 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let store = SwiftDataStore(modelContainer: container) // On-disk stores live in a shared App Group container, so another process // (the share extension) — or, for CloudKit, a sync from another device — - // can commit behind our back. Both surface as - // `.NSPersistentStoreRemoteChange` (persistent-history tracking is on for - // on-disk stores). Core Data posts that notification for local saves as - // well, so the source filters history by this store instance's author - // before forwarding only external writes into `changes()`. This makes a - // share-extension add show up live in the running app (debug included), - // not just on next launch. + // can commit behind our back. SwiftData's history observer detects + // changes to this container. The source filters history by this store + // instance's author before forwarding only external writes into + // `changes()`. A share-extension add then appears live in the running + // app (debug included), not just on next launch. if storage.observesRemoteChanges { - if let storeURL = container.configurations.first?.url { - try store.startObservingRemoteChanges(PersistentStoreRemoteChangeSource( - modelContainer: container, - storeURL: storeURL, - localTransactionAuthor: store.localTransactionAuthor, - center: .default, - )) - } else { - assertionFailure("An on-disk Where store must have a resolved URL") - } + try store.startObservingRemoteChanges(HistoryObserverRemoteChangeSource( + modelContainer: container, + localTransactionAuthor: store.localTransactionAuthor, + )) } return store } @@ -546,7 +534,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// fan-out from `remoteChangeSource`, so the remote-import path is /// exercisable without CloudKit or a device. The production equivalent /// is `make(storage: .cloudKit(appGroupIdentifier:))`, which wires a - /// `PersistentStoreRemoteChangeSource`. `@_spi(Testing)` (per the + /// `HistoryObserverRemoteChangeSource`. `@_spi(Testing)` (per the /// agents.md) so the remote-change wiring stays folded into a factory — /// there's no public `startObservingRemoteChanges` to call twice. @_spi(Testing) @@ -562,7 +550,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// Variant that exposes the shared container to persistence-boundary /// tests, allowing them to commit a same-generation external write before - /// driving the corresponding remote-change notification. + /// driving the corresponding scripted remote-change signal. @_spi(Testing) public static func inMemory( modelContainer: ModelContainer, @@ -749,8 +737,8 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { return try await withExclusiveStoreOperation { let peer = ModelContext(modelContainer) // A persistent-store transaction becomes fetch-visible atomically with - // its history row, but Core Data is allowed to post the corresponding - // remote-change notification later. Bracket every table fetch with the + // its history row, but SwiftData may signal the observer later. + // Bracket every table fetch with the // history head from this same peer context: if an external transaction // lands anywhere across the block, its monotonically increasing id // changes and the assembled value is rejected. Our own `perform`s are @@ -778,9 +766,9 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } - /// The durable store generation used to bracket a multi-table read. Unlike - /// `.NSPersistentStoreRemoteChange`, persistent history is committed in the - /// same transaction as the rows it describes, so it cannot lag visibility. + /// The durable store generation used to bracket a multi-table read. History + /// is committed in the same transaction as the rows it describes, so it + /// cannot lag change observation. private static func latestHistoryTransactionID(in context: ModelContext) throws -> Int64 { var descriptor = HistoryDescriptor( sortBy: [SortDescriptor(\.transactionIdentifier, order: .reverse)], diff --git a/Where/WhereCore/Tests/StoreRemoteChangeSourceTests.swift b/Where/WhereCore/Tests/StoreRemoteChangeSourceTests.swift index e7f208e20..5bc6ef00a 100644 --- a/Where/WhereCore/Tests/StoreRemoteChangeSourceTests.swift +++ b/Where/WhereCore/Tests/StoreRemoteChangeSourceTests.swift @@ -1,12 +1,11 @@ -import CoreData import Foundation import SwiftData import Testing @_spi(Testing) @testable import WhereCore /// The `StoreRemoteChangeSource` seam that makes the CloudKit remote-import path -/// drivable off-device: the scripted double on demand, and the production source -/// from a posted Core Data notification. +/// drivable off-device: the scripted double on demand, and a production history +/// observer watching a temporary on-disk store. struct StoreRemoteChangeSourceTests { /// The scripted double yields on `yield()`, so a test can drive the /// store-observes-remote-change path deterministically. @@ -19,136 +18,139 @@ struct StoreRemoteChangeSourceTests { #expect(await firstPing(stream, within: .seconds(2))) } - /// The production source forwards a remote-change notification identifying - /// the Where store it was built to observe. - @Test func persistentSourceForwardsExternalAuthorForItsStore() async throws { - let center = NotificationCenter() - let container = try SwiftDataStore.makeContainer(storage: .inMemory) - let storeURL = try #require(container.configurations.first?.url) - let source = try PersistentStoreRemoteChangeSource( - modelContainer: container, - storeURL: storeURL, + /// A second container over the same file represents a sibling process or + /// CloudKit import. Its transaction must reach the source without a posted + /// test notification. + @Test func historyObserverForwardsExternalAuthorForItsStore() async throws { + let store = try TemporaryHistoryStore() + defer { store.remove() } + let sibling = try store.makeContainer() + let source = try HistoryObserverRemoteChangeSource( + modelContainer: store.container, localTransactionAuthor: "where-local", - center: center, ) let stream = source.remoteChanges - let external = ModelContext(container) + let external = ModelContext(sibling) external.author = "where-other-process" external.insert(SDTrackedRegion(regionID: "us-TX", generationID: .initial)) try external.save() - withExtendedLifetime(source) { - center.post( - name: .NSPersistentStoreRemoteChange, - object: nil, - userInfo: [NSPersistentStoreURLKey: storeURL], - ) - } - - #expect(await firstPing(stream, within: .seconds(2))) + #expect(await firstPing(stream, within: .seconds(5))) } - /// Observation starts after the initial history cursor is captured. An external commit in - /// that setup interval has already posted its notification to nobody, so the source must run - /// one history catch-up after registering rather than waiting for an unrelated later write. - @Test func persistentSourceCatchesCommitBetweenHistoryBaselineAndObservation() async throws { - let center = NotificationCenter() - let container = try SwiftDataStore.makeContainer(storage: .inMemory) - let storeURL = try #require(container.configurations.first?.url) - let source = try PersistentStoreRemoteChangeSource( - modelContainer: container, - storeURL: storeURL, + /// A commit between the history baseline and observer startup can miss the + /// observer's first event. The source's catch-up must still forward it. + @Test func historyObserverCatchesCommitBetweenBaselineAndObservation() async throws { + let store = try TemporaryHistoryStore() + defer { store.remove() } + let sibling = try store.makeContainer() + let source = try HistoryObserverRemoteChangeSource( + modelContainer: store.container, localTransactionAuthor: "where-local", - center: center, testingAfterHistoryBaseline: { - let external = ModelContext(container) + let external = ModelContext(sibling) external.author = "where-other-process" external.insert(SDTrackedRegion(regionID: "us-TX", generationID: .initial)) try external.save() }, ) - #expect(await firstPing(source.remoteChanges, within: .seconds(2))) + #expect(await firstPing(source.remoteChanges, within: .seconds(5))) } - /// Core Data posts its remote-change notification for the app's own saves - /// too. The transaction author prevents those local commits from running a - /// second, full remote reconciliation after their focused one. - @Test func persistentSourceSuppressesItsLocalTransactionAuthor() async throws { - let center = NotificationCenter() - let container = try SwiftDataStore.makeContainer(storage: .inMemory) - let storeURL = try #require(container.configurations.first?.url) + /// The transaction author prevents local commits from running a second, + /// full remote reconciliation after their focused one. + @Test func historyObserverSuppressesItsLocalTransactionAuthor() async throws { + let store = try TemporaryHistoryStore() + defer { store.remove() } let localAuthor = "where-local" - let source = try PersistentStoreRemoteChangeSource( - modelContainer: container, - storeURL: storeURL, + let source = try HistoryObserverRemoteChangeSource( + modelContainer: store.container, localTransactionAuthor: localAuthor, - center: center, ) let stream = source.remoteChanges - let local = ModelContext(container) + let local = ModelContext(store.container) local.author = localAuthor local.insert(SDTrackedRegion(regionID: "us-TX", generationID: .initial)) try local.save() - withExtendedLifetime(source) { - center.post( - name: .NSPersistentStoreRemoteChange, - object: nil, - userInfo: [NSPersistentStoreURLKey: storeURL], - ) - } - - #expect(await firstPing(stream, within: .milliseconds(200)) == false) + #expect(await firstPing(stream, within: .milliseconds(500)) == false) } - /// A second SwiftData store in the process (Periscope in the app) also posts - /// `.NSPersistentStoreRemoteChange`; its commits must not invalidate Where's - /// data or the resulting refresh spans feed back into more log-store writes. - @Test func persistentSourceIgnoresChangeForAnotherStore() async throws { - let center = NotificationCenter() - let container = try SwiftDataStore.makeContainer(storage: .inMemory) - let storeURL = try #require(container.configurations.first?.url) - let source = try PersistentStoreRemoteChangeSource( - modelContainer: container, - storeURL: storeURL, + /// A separate SwiftData store (Periscope in the app) must not invalidate + /// Where data or cause a refresh/logging feedback loop. + @Test func historyObserverIgnoresChangeForAnotherStore() async throws { + let whereStore = try TemporaryHistoryStore() + defer { whereStore.remove() } + let otherStore = try TemporaryHistoryStore() + defer { otherStore.remove() } + let source = try HistoryObserverRemoteChangeSource( + modelContainer: whereStore.container, localTransactionAuthor: "where-local", - center: center, ) let stream = source.remoteChanges + let other = ModelContext(otherStore.container) + other.author = "periscope" + other.insert(SDTrackedRegion(regionID: "us-TX", generationID: .initial)) + try other.save() - withExtendedLifetime(source) { - center.post( - name: .NSPersistentStoreRemoteChange, - object: nil, - userInfo: [ - NSPersistentStoreURLKey: URL(fileURLWithPath: "/Periscope.store"), - ], - ) - } - - #expect(await firstPing(stream, within: .milliseconds(200)) == false) + #expect(await firstPing(stream, within: .milliseconds(500)) == false) } - /// Target/selector observation must not make the notification center own - /// the source; otherwise `deinit` can never unregister or finish its tasks. - @Test func persistentSourceIsNotRetainedByNotificationCenter() throws { - let center = NotificationCenter() - let container = try SwiftDataStore.makeContainer(storage: .inMemory) - let storeURL = try #require(container.configurations.first?.url) - weak var weakSource: PersistentStoreRemoteChangeSource? - - try autoreleasepool { - let source = try PersistentStoreRemoteChangeSource( - modelContainer: container, - storeURL: storeURL, + /// Observation tasks must not retain the source past its owner's lifetime. + @Test func historyObserverSourceFinishesWhenReleased() async throws { + let store = try TemporaryHistoryStore() + defer { store.remove() } + weak var weakSource: HistoryObserverRemoteChangeSource? + let stream: AsyncStream + + do { + let source = try HistoryObserverRemoteChangeSource( + modelContainer: store.container, localTransactionAuthor: "where-local", - center: center, ) weakSource = source + stream = source.remoteChanges } #expect(weakSource == nil) + #expect(await firstPing(stream, within: .milliseconds(500)) == false) + } +} + +private struct TemporaryHistoryStore { + let directory: URL + let container: ModelContainer + + init() throws { + directory = FileManager.default.temporaryDirectory.appending( + path: "where-history-observer-\(UUID().uuidString)", + directoryHint: .isDirectory, + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + container = try Self.makeContainer(in: directory) + } + + func makeContainer() throws -> ModelContainer { + try Self.makeContainer(in: directory) + } + + private static func makeContainer(in directory: URL) throws -> ModelContainer { + let schema = Schema(SwiftDataStore.inspectorModelTypes) + let configuration = ModelConfiguration( + schema: schema, + url: directory.appending(path: "Where.store"), + cloudKitDatabase: .none, + ) + return try ModelContainer(for: schema, configurations: [configuration]) + } + + func remove() { + do { + try FileManager.default.removeItem(at: directory) + } catch { + Issue.record("Could not remove temporary history store: \(error)") + } } }