diff --git a/Where/WhereCore/Sources/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index c2e9a7e1..50ad6ab2 100644 --- a/Where/WhereCore/Sources/Journal/DayJournal.swift +++ b/Where/WhereCore/Sources/Journal/DayJournal.swift @@ -49,11 +49,26 @@ public actor DayJournal { /// The scanner is invalidated inline (not just via its async store-change /// observation) so the reconciles below recount from fresh data rather than /// racing it. + private func execute(_ plan: PostWriteReconcilePlan) async { + for step in plan.steps { + switch step { + case .invalidateIssues: + await issueScanner.invalidate() + case .reconcileReminders: + await reminders.reconcile() + case .reconcileIssueAlerts: + await issueAlerts.reconcile() + case .publishWidgets: + await widgets.publish() + case let .publishWidgetsAfterIngest(sample): + await widgets.publishAfterIngest(of: sample) + } + } + } + private func reconcileIssueState() async { await Self.logger.measure(.reconcileIssueState, budget: .seconds(3)) { - await issueScanner.invalidate() - await reminders.reconcile() - await issueAlerts.reconcile() + await execute(PostWriteReconcilePlan.forOutcome(.issueOnly)) } } @@ -64,16 +79,14 @@ public actor DayJournal { /// root points at this method via `BackupCoordinator`'s `onImport` hook. func reconcileAfterDayDataChange() async { await Self.logger.measure(.reconcileAfterDayDataChange, budget: .seconds(5)) { - await reconcileIssueState() - await widgets.publish() + await execute(PostWriteReconcilePlan.forOutcome(.dayDataChanged)) } } /// Reminder/issue fan-out plus the hot-path widget policy: skip a rebuild /// when the sample cannot change what widgets show (same day + region). private func reconcileAfterSampleIngest(_ sample: LocationSample) async { - await reconcileIssueState() - await widgets.publishAfterIngest(of: sample) + await execute(PostWriteReconcilePlan.forOutcome(.sampleIngest(sample))) } // MARK: - Ingestion diff --git a/Where/WhereCore/Sources/Journal/PostWriteReconcilePlan.swift b/Where/WhereCore/Sources/Journal/PostWriteReconcilePlan.swift new file mode 100644 index 00000000..31afcd0b --- /dev/null +++ b/Where/WhereCore/Sources/Journal/PostWriteReconcilePlan.swift @@ -0,0 +1,50 @@ +import Foundation + +/// What kind of store write just committed — drives the post-write fan-out plan in +/// [`PostWriteReconcile`](../../Specifications/PostWriteReconcile/README.md). +public enum PostWriteOutcome: Equatable, Sendable { + /// A single GPS sample append (live ingest or manual sample). + case sampleIngest(LocationSample) + /// Persisted day-level data changed (manual day, bulk ingest, clears, import). + case dayDataChanged + /// Issue dismiss/restore — recount badge/notification only. + case issueOnly +} + +/// One step in the sequential fan-out after a committed write. +public enum ReconcileStep: Equatable, Sendable { + case invalidateIssues + case reconcileReminders + case reconcileIssueAlerts + case publishWidgets + case publishWidgetsAfterIngest(LocationSample) +} + +/// Declarative post-write pipeline derived from ``PostWriteOutcome``. +/// +/// Execution stays in ``DayJournal``; this type is the digest the TLA +/// `reconcilePhase` sequence maps to. +public struct PostWriteReconcilePlan: Equatable, Sendable { + public let steps: [ReconcileStep] + + public init(steps: [ReconcileStep]) { + self.steps = steps + } + + public static func forOutcome(_ outcome: PostWriteOutcome) -> PostWriteReconcilePlan { + let issueSteps: [ReconcileStep] = [ + .invalidateIssues, + .reconcileReminders, + .reconcileIssueAlerts, + ] + switch outcome { + case .issueOnly: + return PostWriteReconcilePlan(steps: issueSteps) + case let .sampleIngest(sample): + return PostWriteReconcilePlan(steps: issueSteps + + [.publishWidgetsAfterIngest(sample)]) + case .dayDataChanged: + return PostWriteReconcilePlan(steps: issueSteps + [.publishWidgets]) + } + } +} diff --git a/Where/WhereCore/Tests/PostWriteReconcilePlanTests.swift b/Where/WhereCore/Tests/PostWriteReconcilePlanTests.swift new file mode 100644 index 00000000..d76247c3 --- /dev/null +++ b/Where/WhereCore/Tests/PostWriteReconcilePlanTests.swift @@ -0,0 +1,44 @@ +import Foundation +import RegionKit +import Testing +import WhereCore + +/// Mirrors [`PostWriteReconcile`](../../Specifications/PostWriteReconcile/README.md) +/// fan-out tiers on the pure plan layer. +struct PostWriteReconcilePlanTests { + private let issueSteps: [ReconcileStep] = [ + .invalidateIssues, + .reconcileReminders, + .reconcileIssueAlerts, + ] + + @Test func issueOnlyOmitsWidgetPublish() { + let plan = PostWriteReconcilePlan.forOutcome(.issueOnly) + #expect(plan.steps == issueSteps) + } + + @Test func dayDataChangedAppendsFullWidgetPublish() { + let plan = PostWriteReconcilePlan.forOutcome(.dayDataChanged) + #expect(plan.steps == issueSteps + [.publishWidgets]) + } + + @Test func sampleIngestUsesAfterIngestWidgetPolicy() { + let sample = LocationSample( + timestamp: Date(timeIntervalSince1970: 1_735_689_600), + coordinate: Coordinate(latitude: 37.78, longitude: -122.42), + horizontalAccuracy: 10, + source: .gpsSignificantChange, + ) + let plan = PostWriteReconcilePlan.forOutcome(.sampleIngest(sample)) + #expect(plan.steps == issueSteps + [.publishWidgetsAfterIngest(sample)]) + } + + @Test func bulkAndManualDayPathsShareDayDataPlan() { + let dayPlan = PostWriteReconcilePlan.forOutcome(.dayDataChanged) + #expect(dayPlan.steps.last == .publishWidgets) + #expect(!dayPlan.steps.contains { step in + if case .publishWidgetsAfterIngest = step { return true } + return false + }) + } +}