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
2 changes: 1 addition & 1 deletion TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ inbox rather than here.
# Open issues

## PX (Exploratory)
- feat: Complete the iOS 27 / HistoryObserver direction — PR #318 (`a32b8b05`) shipped the iOS 27 minimum in `Package.swift:8` and `Project.swift:4`; macOS remains 26. The remaining part is replacing WhereCore's remote-change notification/history adapter (`Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift`) with HistoryObserver. PR #319 is open, not merged, as of 2026-09-21. Keep this originating cross-target item open until that follow-on lands; do not repeat the deployment uplift. (human; narrowed 2026-09-21)
- feat: Complete the iOS 27 / HistoryObserver direction — PR #318 (`a32b8b05`) shipped the iOS 27 minimum in `Package.swift:8` and `Project.swift:4`; macOS remains 26. The HistoryObserver replacement for WhereCore's remote-change notification/history adapter (`Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift`) is implemented in PR #319, which is open and not merged as of 2026-09-21. Keep this originating cross-target item open until that follow-on lands; do not repeat the deployment uplift. (human; narrowed 2026-09-21)

## 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-09-21), 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, but the source filter cannot match these implicit sites — the `architecture` job at `.github/workflows/ci.yml:72-73` reaches `bumper config`/`test`/`lint` through `test:253-261`. **The missing regression:** 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 statically 2026-09-21 — still 12 implicit sites; no fresh lint execution claimed)
Expand Down
6 changes: 3 additions & 3 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
101 changes: 36 additions & 65 deletions Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import CoreData
import Foundation
import Observation
import PeriscopeCore
import SwiftData

Expand All @@ -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
Expand All @@ -26,81 +25,59 @@ protocol StoreRemoteChangeSource: AnyObject, Sendable {
var remoteChanges: AsyncStream<Void> { 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<Void>

private let center: NotificationCenter
private let observedStoreURL: URL
private let observer: HistoryObserver
private let continuation: AsyncStream<Void>.Continuation
private let candidateContinuation: AsyncStream<Void>.Continuation
private let classificationTask: Task<Void, Never>
private let observationTask: Task<Void, Never>

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,
)
}
#endif

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),
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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<DefaultHistoryTransaction>(
predicate: #Predicate { transaction in
Expand Down
Loading
Loading