Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-16 16:20:12 -06:00
parent 881ac5ad4c
commit e9de34355b
6 changed files with 296 additions and 56 deletions

View File

@ -1,18 +1,17 @@
import Foundation
import Observation
import SwiftData
import CoreData
import Bedrock
import WidgetKit
@MainActor
@Observable
final class RitualStore: RitualStoreProviding {
final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
private static let dataIntegrityMigrationVersion = 1
private static let dataIntegrityMigrationVersionKey = "ritualDataIntegrityMigrationVersion"
@ObservationIgnored private let modelContainer: ModelContainer
@ObservationIgnored private var modelContext: ModelContext
@ObservationIgnored let modelContainer: ModelContainer
@ObservationIgnored var modelContext: ModelContext
@ObservationIgnored private let seedService: RitualSeedProviding
@ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding
@ObservationIgnored private let calendar: Calendar
@ -20,14 +19,15 @@ final class RitualStore: RitualStoreProviding {
@ObservationIgnored private let isRunningTests: Bool
@ObservationIgnored private let dayFormatter: DateFormatter
@ObservationIgnored private let displayFormatter: DateFormatter
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
@ObservationIgnored private var remoteChangeEventCount: Int = 0
@ObservationIgnored let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
isEnabled: true,
logIdentifier: "RitualStore.CloudKitSync"
)
private(set) var rituals: [Ritual] = []
private(set) var currentRituals: [Ritual] = []
private(set) var pastRituals: [Ritual] = []
private(set) var lastErrorMessage: String?
private(set) var lastRemoteChangeDate: Date?
private(set) var dataRefreshVersion: Int = 0
private var analyticsNeedsRefresh = true
private var cachedDatesWithActivity: Set<Date> = []
@ -52,7 +52,7 @@ final class RitualStore: RitualStoreProviding {
updateCurrentTimeOfDay()
analyticsNeedsRefresh = true
insightCardsNeedRefresh = true
reloadRituals()
reloadData()
}
}
@ -93,34 +93,23 @@ final class RitualStore: RitualStoreProviding {
nowProvider()
}
deinit {
if let observer = remoteChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
}
/// Observes CloudKit remote change notifications to auto-refresh UI when iCloud data syncs.
private func observeRemoteChanges() {
remoteChangeObserver = NotificationCenter.default.addObserver(
forName: .NSPersistentStoreRemoteChange,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleRemoteStoreChange()
}
cloudKitSyncManager.startObserving { [weak self] in
self?.handleRemoteStoreChange()
}
}
private func handleRemoteStoreChange() {
remoteChangeEventCount += 1
let eventCount = remoteChangeEventCount
lastRemoteChangeDate = now()
// SwiftData may keep stale registered objects in long-lived contexts.
// Recreate the context on remote store changes so fetches observe latest merged values.
modelContext = ModelContext(modelContainer)
Design.debugLog("Received remote store change #\(eventCount); reloading rituals")
reloadRituals()
let result = cloudKitSyncManager.processObservedRemoteChange(
modelContext: &modelContext,
modelContainer: modelContainer
)
Design.debugLog(
"RitualStore.CloudKitSync: received remote store change #\(result.eventCount); " +
"rebuiltContext=\(result.didRebuildModelContext); reloading rituals"
)
reloadData()
// Also refresh widgets when data arrives from other devices
WidgetCenter.shared.reloadAllTimelines()
}
@ -144,7 +133,7 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals and derived state for current date/time.
func refresh() {
updateCurrentTimeOfDay()
reloadRituals()
reloadData()
checkForCompletedArcs()
}
@ -895,7 +884,7 @@ final class RitualStore: RitualStoreProviding {
}
private func loadRitualsIfNeeded() {
reloadRituals()
reloadData()
// No longer auto-seed rituals on fresh install
// Users start with empty state and create their own rituals
}
@ -916,7 +905,7 @@ final class RitualStore: RitualStoreProviding {
}
UserDefaults.standard.set(Self.dataIntegrityMigrationVersion, forKey: Self.dataIntegrityMigrationVersionKey)
reloadRituals()
reloadData()
} catch {
lastErrorMessage = error.localizedDescription
}
@ -1002,7 +991,7 @@ final class RitualStore: RitualStoreProviding {
return didChange
}
private func reloadRituals() {
func reloadData() {
do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
updateDerivedData()
@ -1017,7 +1006,7 @@ final class RitualStore: RitualStoreProviding {
private func saveContext() {
do {
try modelContext.save()
reloadRituals()
reloadData()
// Widget timeline reloads can destabilize test hosts; skip in tests.
if !isRunningTests {
WidgetCenter.shared.reloadAllTimelines()
@ -1481,7 +1470,7 @@ final class RitualStore: RitualStoreProviding {
func refreshAnalyticsIfNeeded() {
refreshAnalyticsCacheIfNeeded()
}
// MARK: - Debug / Demo Data
#if DEBUG
@ -1590,5 +1579,6 @@ final class RitualStore: RitualStoreProviding {
Design.debugLog("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.")
}
#endif
}

View File

@ -11,11 +11,13 @@ struct RootView: View {
@State private var analyticsPrewarmTask: Task<Void, Never>?
@State private var cloudKitFallbackRefreshTask: Task<Void, Never>?
@State private var aggressiveCloudKitRefreshTasks: [Task<Void, Never>] = []
@State private var macCloudKitPulseTask: Task<Void, Never>?
@State private var isForegroundRefreshing = false
@State private var isResumingFromBackground = false
private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15
private let debugForegroundRefreshMinimumSeconds: TimeInterval = 0.8
private let debugForegroundRefreshKey = "debugForegroundRefreshNextForeground"
private let macCloudKitPulseIntervalSeconds: TimeInterval = 5
/// The available tabs in the app.
enum RootTab: Hashable {
@ -109,6 +111,11 @@ struct RootView: View {
transaction.animation = nil
}
}
.onAppear {
if scenePhase == .active {
startMacCloudKitPulseLoopIfNeeded()
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
store.reminderScheduler.clearBadge()
@ -136,12 +143,14 @@ struct RootView: View {
)
scheduleAggressiveCloudKitRefreshes()
scheduleCloudKitFallbackRefresh()
startMacCloudKitPulseLoopIfNeeded()
} else if newPhase == .background {
// Prepare for next resume
isResumingFromBackground = true
cloudKitFallbackRefreshTask?.cancel()
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
aggressiveCloudKitRefreshTasks.removeAll()
stopMacCloudKitPulseLoop()
}
}
.onChange(of: selectedTab) { _, _ in
@ -159,6 +168,7 @@ struct RootView: View {
.onDisappear {
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
aggressiveCloudKitRefreshTasks.removeAll()
stopMacCloudKitPulseLoop()
}
}
@ -219,8 +229,7 @@ struct RootView: View {
cloudKitFallbackRefreshTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled else { return }
let receivedRemoteChangeSinceActivation = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false
guard !receivedRemoteChangeSinceActivation else { return }
guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
store.refresh()
}
}
@ -242,6 +251,25 @@ struct RootView: View {
}
}
private func startMacCloudKitPulseLoopIfNeeded() {
guard store.supportsMacCloudKitImportPulseFallback else { return }
macCloudKitPulseTask?.cancel()
macCloudKitPulseTask = Task { @MainActor in
Design.debugLog("RootView.CloudKitSync: starting macOS pulse loop interval=\(macCloudKitPulseIntervalSeconds)s")
while !Task.isCancelled {
guard scenePhase == .active else { return }
store.forceCloudKitImportPulse(reason: "active_loop")
try? await Task.sleep(for: .seconds(macCloudKitPulseIntervalSeconds))
}
}
}
private func stopMacCloudKitPulseLoop() {
macCloudKitPulseTask?.cancel()
macCloudKitPulseTask = nil
}
}
#Preview {

View File

@ -0,0 +1,176 @@
# Bedrock Deferred Refactor Plan
This plan captures the **remaining** reusable extraction work that was intentionally deferred during the safe-core sync refactor.
## Current Baseline (Already Done)
- Reusable sync lifecycle utility in Bedrock:
- `Bedrock/Sources/Bedrock/Storage/SwiftDataCloudKitSyncManager.swift`
- Reusable refresh throttling utility in Bedrock:
- `Bedrock/Sources/Bedrock/Storage/SwiftDataRefreshThrottler.swift`
- Bedrock-first setup guide:
- `Bedrock/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md`
## Deferred Items (This Plan)
### Phase 1 - Derived Data Coordinator (Medium Risk)
**Goal:** Extract generic derived-state recomputation orchestration from app stores.
**Candidate extraction:**
- Generic coordinator to run ordered recompute steps after reload:
- fetch/load -> derive grouped/sorted slices -> invalidate downstream caches -> optional side effects
**Bedrock target:**
- `Bedrock/Sources/Bedrock/Storage/SwiftDataDerivedStateCoordinator.swift`
**App integration target:**
- `Andromida/Andromida/App/State/RitualStore.swift`
**Guardrail:**
- Keep ritual-specific filtering/sorting closures in app, only orchestration in Bedrock.
---
### Phase 2 - Analytics Cache Framework (Medium Risk)
**Goal:** Reuse cache invalidation and lazy recomputation structure without moving domain metrics.
**Candidate extraction:**
- Generic keyed cache invalidation container
- "dirty flag + recompute-on-demand" helper patterns
**Bedrock target:**
- `Bedrock/Sources/Bedrock/Storage/SwiftDataAnalyticsCacheController.swift`
**App integration target:**
- `Andromida/Andromida/App/State/RitualStore.swift`
**Guardrail:**
- Keep metric formulas and ritual semantics in app.
---
### Phase 3 - Refresh Orchestration Utility (Medium Risk)
**Goal:** Centralize recurring refresh sequences used across tabs/lifecycle transitions.
**Candidate extraction:**
- Reusable refresh runner with:
- optional overlay timing
- delayed aggressive refresh scheduling
- cancellation management
**Bedrock target:**
- `Bedrock/Sources/Bedrock/Storage/SwiftDataRefreshCoordinator.swift`
**App integration target:**
- `Andromida/Andromida/App/Views/RootView.swift`
**Guardrail:**
- Keep app tab selection and app-specific scheduler calls local.
---
### Phase 4 - Migration Scaffolding (Higher Risk)
**Goal:** Reuse migration structure, not migration rules.
**Candidate extraction:**
- Versioned migration runner skeleton
- Shared logging and one-time version persistence wrapper
**Bedrock target:**
- `Bedrock/Sources/Bedrock/Storage/SwiftDataMigrationRunner.swift`
**App integration target:**
- `Andromida/Andromida/App/State/RitualStore.swift`
**Guardrail:**
- Keep ritual data integrity rule implementations in app.
---
### Phase 5 - Documentation and Templates (Low Risk)
**Goal:** Expand Bedrock docs to cover phases 1-4 with copy/paste adapter examples.
**Bedrock docs target:**
- `Bedrock/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md`
- `Bedrock/README.md`
**Andromida docs target:**
- Keep short pointers only; Bedrock remains source of truth.
## Acceptance Criteria Per Phase
- No app-domain naming (`Ritual`, etc.) in new Bedrock symbols/docs.
- `xcodebuild -scheme Andromida` passes after each phase.
- Existing iPhone/iPad sync behavior unchanged.
- No additional runtime-only workaround logic introduced without explicit opt-in.
## Rollback Strategy
- Commit each phase separately in Bedrock and Andromida repos.
- If behavior drifts, revert only latest phase commit(s), keep previous phases.
- Avoid multi-phase combined commits.
## Recommended Execution Order
1. Phase 1 (Derived state coordinator)
2. Phase 3 (Refresh orchestration utility)
3. Phase 2 (Analytics cache framework)
4. Phase 4 (Migration scaffolding)
5. Phase 5 (Docs/templates refresh)
## Notes
- This plan is intentionally incremental and conservative.
- Safe-core extraction stays as baseline; deferred phases should not block current product work.
---
## Known Issue Backlog
### KIB-001 - Mac runtime inbound CloudKit updates stall while app stays active
**Problem statement**
- Mac runtime does not reliably reflect incoming CloudKit changes from iPhone/iPad while app remains active.
- Updates appear after app lifecycle transition (foreground/background or relaunch).
**Observed behavior**
- Mac -> iPhone/iPad sync propagates quickly (about 1-2 seconds).
- iPhone/iPad -> iPhone/iPad sync propagates quickly.
- iPhone/iPad -> Mac often does not appear until activation transition.
- Remote change debug logs are absent on Mac during active-state misses.
**Repro steps**
1. Keep Mac app active on Today or Rituals screen.
2. On iPhone or iPad, toggle one or more habit completions.
3. Observe Mac app does not update in real time.
4. Trigger app activation transition on Mac (switch away/back or relaunch).
5. Observe pending updates appear.
**Impact**
- Cross-device real-time expectation is broken for Mac runtime inbound updates.
- User trust risk for multi-device usage.
**Current status**
- Not a regression from Bedrock safe-core extraction; behavior also reproduced on original direct observer path.
- iPhone/iPad path remains healthy.
**Hypothesis**
- Runtime/platform-level import delivery behavior for SwiftData+CloudKit on Mac runtime while active (push/import callbacks not firing consistently).
**Deferred investigation tasks**
- Capture high-signal telemetry during active-state misses:
- last remote change callback time
- last lifecycle-triggered refresh time
- local store update timestamps
- Compare iOS-on-Mac runtime vs native iOS device callback cadence.
- Evaluate dedicated Mac sync architecture options:
- explicit sync UX fallback
- alternative persistence/sync stack for Mac runtime if real-time inbound is mandatory.
**Acceptance criteria for closure**
- While Mac app remains active, inbound updates from iPhone/iPad appear without requiring foreground/background transitions in repeated test runs.

11
PRD.md
View File

@ -628,3 +628,14 @@ Andromida/
| 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation |
| 1.2 | February 2026 | Added deterministic UI-test launch harness and expanded critical UI flow coverage |
| 1.3 | February 2026 | Added reusable SwiftData to CloudKit sync requirements, runtime expectations, and two-device verification standard |
---
## Known Limitations
### KIB-001 - Mac Runtime Inbound CloudKit Updates May Require Activation Transition
- **Summary**: On Mac runtime, inbound SwiftData+CloudKit updates from iPhone/iPad may not appear while app remains active.
- **Observed**: iPhone/iPad to iPhone/iPad sync is near-real-time; Mac to iPhone/iPad sync is near-real-time; iPhone/iPad to Mac may require foreground/background transition or relaunch.
- **Status**: Known platform/runtime behavior under investigation; not introduced by recent Bedrock safe-core extraction.
- **Tracking**: Detailed investigation notes are maintained in `BEDROCK_DEFERRED_REFACTOR_PLAN.md` under `Known Issue Backlog (KIB-001)`.

View File

@ -40,10 +40,13 @@ Entitlements should reference variables, not hard-coded values, where possible.
## 4) Runtime Sync Behavior
- Prefer Bedrock `SwiftDataCloudKitSyncManager` as the reusable remote observer/lifecycle component.
- Prefer Bedrock `SwiftDataStore` + `SwiftDataCloudKitStore` to avoid app-specific pulse boilerplate.
- Observe `.NSPersistentStoreRemoteChange` to detect remote merges.
- On remote change, refetch from SwiftData and invalidate derived caches.
- For long-lived stores, recreate `ModelContext` on remote change before refetch when stale objects are observed.
- Keep a foreground fallback refresh as a safety net, but do not rely on force-quit/relaunch behavior.
- On remote change, call Bedrock `processObservedRemoteChange(modelContext:modelContainer:)` before refetch.
- Rebuild long-lived contexts only when safe (`hasChanges == false`) to avoid dropping unsaved local edits.
- Implement protocol reload hook (`reloadData`) to run your store-specific fetch step.
- Keep iOS-on-Mac pulsing loop in the root scene lifecycle (`active` only, cancel on `background`).
- Keep a foreground fallback refresh as a safety net; gate it with Bedrock `hasReceivedRemoteChange(since:)`.
- Emit structured logs for remote sync events (event count + timestamp) for debugging.
## 5) UI Freshness Requirements
@ -68,6 +71,7 @@ Test all cases on two physical devices with the same Apple ID and same app flavo
- Device logs: filter by sync logger category (for example `CloudKitSync`).
- CloudKit Console: validate record updates in the app container private database.
- If pushes are delivered but UI is stale, investigate context freshness and view invalidation, not transport.
- If APNs is unreliable on Mac runtime, validate that pulse logs appear every interval while active.
## 8) Reuse Checklist for New Apps
@ -76,7 +80,9 @@ Before shipping any new SwiftData+CloudKit app:
- [ ] Capabilities: iCloud/CloudKit + Push + Remote Notifications are enabled
- [ ] Entitlements include `aps-environment` and correct container IDs
- [ ] xcconfig defines `APS_ENVIRONMENT` per configuration
- [ ] Remote change observer reloads data and invalidates caches
- [ ] Remote change observer uses Bedrock `processObservedRemoteChange(...)` + reloads data
- [ ] Store conforms to Bedrock `SwiftDataCloudKitStore`
- [ ] Foreground fallback is gated by Bedrock `hasReceivedRemoteChange(since:)`
- [ ] UI has deterministic invalidation on remote reload
- [ ] Two-device batch-update test passes without manual refresh
- [ ] CloudKit Console verification documented in README/PRD

View File

@ -68,17 +68,16 @@ import Bedrock
@MainActor
@Observable
final class AppDataStore {
@ObservationIgnored private let modelContainer: ModelContainer
@ObservationIgnored private var modelContext: ModelContext
@ObservationIgnored private let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
final class AppDataStore: SwiftDataCloudKitStore {
@ObservationIgnored let modelContainer: ModelContainer
@ObservationIgnored var modelContext: ModelContext
@ObservationIgnored let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
isEnabled: true,
logIdentifier: "AppCloudKitSync"
)
private(set) var entities: [PrimaryEntity] = []
private(set) var dataRefreshVersion: Int = 0
private(set) var lastRemoteChangeDate: Date?
init(modelContext: ModelContext) {
self.modelContainer = modelContext.container
@ -94,17 +93,22 @@ final class AppDataStore {
}
private func handleRemoteStoreChange() {
lastRemoteChangeDate = cloudKitSyncManager.lastRemoteChangeDate
let eventCount = cloudKitSyncManager.remoteChangeEventCount
// Important when long-lived contexts become stale after remote merges.
modelContext = ModelContext(modelContainer)
Design.debugLog("Received remote store change #\(eventCount); reloading")
let result = cloudKitSyncManager.processObservedRemoteChange(
modelContext: &modelContext,
modelContainer: modelContainer
)
Design.debugLog(
"Received remote store change #\(result.eventCount); " +
"rebuiltContext=\(result.didRebuildModelContext); reloading"
)
reloadEntities()
}
func refresh() {
reloadData()
}
func reloadData() {
reloadEntities()
}
@ -128,17 +132,26 @@ struct RootView: View {
@Bindable var store: AppDataStore
@Environment(\.scenePhase) private var scenePhase
@State private var fallbackRefreshTask: Task<Void, Never>?
@State private var macPulseTask: Task<Void, Never>?
private let macPulseIntervalSeconds: TimeInterval = 5
var body: some View {
// Ensure body observes remote reload increments.
let _ = store.dataRefreshVersion
ContentView(store: store)
.onAppear {
if scenePhase == .active {
startMacPulseLoopIfNeeded()
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
startMacPulseLoopIfNeeded()
scheduleCloudKitFallbackRefresh()
} else if newPhase == .background {
fallbackRefreshTask?.cancel()
stopMacPulseLoop()
}
}
}
@ -149,11 +162,27 @@ struct RootView: View {
fallbackRefreshTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled else { return }
let gotRemoteSinceActive = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false
guard !gotRemoteSinceActive else { return }
guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
store.refresh()
}
}
private func startMacPulseLoopIfNeeded() {
guard store.supportsMacCloudKitImportPulseFallback else { return }
macPulseTask?.cancel()
macPulseTask = Task { @MainActor in
while !Task.isCancelled {
guard scenePhase == .active else { return }
store.forceCloudKitImportPulse(reason: "active_loop")
try? await Task.sleep(for: .seconds(macPulseIntervalSeconds))
}
}
}
private func stopMacPulseLoop() {
macPulseTask?.cancel()
macPulseTask = nil
}
}
```