From e9de34355bae6ef25868f02934f4cbb126d4d612 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 16 Feb 2026 16:20:12 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida/App/State/RitualStore.swift | 62 ++++---- Andromida/App/Views/RootView.swift | 32 +++- BEDROCK_DEFERRED_REFACTOR_PLAN.md | 176 ++++++++++++++++++++++ PRD.md | 11 ++ SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md | 14 +- SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md | 57 +++++-- 6 files changed, 296 insertions(+), 56 deletions(-) create mode 100644 BEDROCK_DEFERRED_REFACTOR_PLAN.md diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 619b6a4..fd46514 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -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 = [] @@ -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()) 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 } diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index ac1cb07..5ef6f2d 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -11,11 +11,13 @@ struct RootView: View { @State private var analyticsPrewarmTask: Task? @State private var cloudKitFallbackRefreshTask: Task? @State private var aggressiveCloudKitRefreshTasks: [Task] = [] + @State private var macCloudKitPulseTask: Task? @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 { diff --git a/BEDROCK_DEFERRED_REFACTOR_PLAN.md b/BEDROCK_DEFERRED_REFACTOR_PLAN.md new file mode 100644 index 0000000..ec658e1 --- /dev/null +++ b/BEDROCK_DEFERRED_REFACTOR_PLAN.md @@ -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. diff --git a/PRD.md b/PRD.md index c2a0c4e..97008a7 100644 --- a/PRD.md +++ b/PRD.md @@ -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)`. diff --git a/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md b/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md index 53a0c5b..10be9d1 100644 --- a/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md +++ b/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md @@ -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 diff --git a/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md b/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md index b33e6b7..5bffafd 100644 --- a/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md +++ b/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md @@ -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? + @State private var macPulseTask: Task? + 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 + } } ```