Skip to content
Draft
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
27 changes: 20 additions & 7 deletions Where/WhereCore/Sources/Journal/DayJournal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions Where/WhereCore/Sources/Journal/PostWriteReconcilePlan.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
44 changes: 44 additions & 0 deletions Where/WhereCore/Tests/PostWriteReconcilePlanTests.swift
Original file line number Diff line number Diff line change
@@ -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
})
}
}