diff --git a/PRD.md b/PRD.md index 1311f6f..c2a0c4e 100644 --- a/PRD.md +++ b/PRD.md @@ -266,6 +266,23 @@ Implementation note: Onboarding flows and root-level shell transitions should av | TR-DATA-04 | Use App Group shared container for widget data access | | TR-DATA-05 | Run a startup integrity migration to normalize arc date ranges, in-progress arc state, and persisted sort indexes | | TR-DATA-06 | Enable iCloud runtime compatibility by shipping `com.apple.developer.ubiquity-kvstore-identifier` and `remote-notification` background mode when CloudKit/KVS sync is enabled | +| TR-DATA-07 | For SwiftData CloudKit sync, ship Push Notifications entitlement (`aps-environment`) with per-configuration environment values | +| TR-DATA-08 | Observe `.NSPersistentStoreRemoteChange` and reload SwiftData-backed state when remote merges arrive | +| TR-DATA-09 | Maintain a foreground sync safety-net refresh, but do not rely on app relaunch for cross-device update visibility | +| TR-DATA-10 | Ensure UI invalidation on each remote merge so batched updates appear without manual pull-to-refresh | +| TR-DATA-11 | Emit sync diagnostics (remote event count and timestamp) to support field debugging on physical devices | +| TR-DATA-12 | Validate every release with a two-device real-time sync test matrix (single toggle, rapid batch toggles, background/foreground recovery) | + +#### 5.3.1 SwiftData to CloudKit Reuse Standard + +Use `SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md` as the canonical implementation and QA checklist for any app that uses SwiftData with CloudKit. + +Standard coverage includes: + +- capabilities/entitlements baseline (CloudKit, Push Notifications, Remote Notifications background mode) +- runtime remote-change handling and stale-context mitigation +- deterministic UI refresh behavior for remote merges and rapid batched edits +- repeatable two-device verification and CloudKit Console validation flow ### 5.4 Third-Party Dependencies @@ -579,6 +596,7 @@ Andromida/ | `Andromida/Shared/Theme/RitualsTheme.swift` | Bedrock theme configuration | | `Andromida/Shared/BrandingConfig.swift` | Branding constants | | `Andromida/Shared/Configuration/Base.xcconfig` | Build configuration source of truth | +| `SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md` | Reusable SwiftData to CloudKit sync standard and test checklist | | `Andromida/Resources/LaunchScreen.storyboard` | Native launch screen | | `Andromida/App/State/RitualStore.swift` | Primary data store | | `Andromida/App/State/SettingsStore.swift` | Settings with cloud sync | @@ -609,3 +627,4 @@ Andromida/ | 1.0 | February 2026 | Initial PRD based on implemented features | | 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 | diff --git a/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md b/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md new file mode 100644 index 0000000..322fbd6 --- /dev/null +++ b/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md @@ -0,0 +1,76 @@ +# SwiftData to CloudKit Sync Requirements (Reusable) + +Use this checklist for any iOS app that uses SwiftData with CloudKit and requires near-real-time multi-device sync. + +## 1) Capabilities and Entitlements + +- Enable `iCloud` with `CloudKit` for the app target. +- Enable `Push Notifications` for the app target. +- Enable `Background Modes > Remote notifications`. +- Add App Group if app + widget share local SQLite. + +Required entitlement keys: +- `com.apple.developer.icloud-container-identifiers` +- `com.apple.developer.icloud-services` including `CloudKit` +- `com.apple.developer.ubiquity-kvstore-identifier` (if KVS is used) +- `aps-environment` (must resolve per config) +- `com.apple.security.application-groups` (if widget/app group storage is used) + +## 2) Build Configuration + +Use xcconfig variables so environments are explicit and portable: + +- Debug: `APS_ENVIRONMENT = development` +- Release: `APS_ENVIRONMENT = production` + +Entitlements should reference variables, not hard-coded values, where possible. + +## 3) Model and Schema Constraints (SwiftData + CloudKit) + +- Avoid `@Attribute(.unique)` in CloudKit-mirrored models. +- Ensure all stored properties have defaults or are optional. +- Keep relationships optional for CloudKit compatibility. +- Use additive schema evolution only (add fields/models; do not remove/rename/change types in place). + +## 4) Runtime Sync Behavior + +- 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. +- Emit structured logs for remote sync events (event count + timestamp) for debugging. + +## 5) UI Freshness Requirements + +- UI must re-render on each remote merge, even for batched updates. +- Keep an observable refresh version/counter and increment on each successful reload. +- Ensure list/detail views do not rely on stale assumptions when models are updated remotely. +- Make high-frequency interaction rows fully tappable to reduce missed user actions. + +## 6) Verification Matrix + +Test all cases on two physical devices with the same Apple ID and same app flavor: + +1. Single toggle on Device A appears on Device B while both apps are open. +2. Rapid batch toggles on Device A all appear on Device B without manual pull-to-refresh. +3. Device B in background receives updates after foregrounding (without force quit). +4. Airplane mode recovery syncs correctly after reconnection. +5. Simultaneous edits resolve predictably (CloudKit last-writer-wins). + +## 7) Observability and Console Checks + +- 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. + +## 8) Reuse Checklist for New Apps + +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 +- [ ] 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 new file mode 100644 index 0000000..99bea3f --- /dev/null +++ b/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md @@ -0,0 +1,188 @@ +# SwiftData to CloudKit Sync Template (Copy/Paste) + +Use this as a starter for new apps that need near-real-time SwiftData sync across Apple devices. + +## 1) Build Config (xcconfig) + +```xcconfig +// Debug.xcconfig +#include "Base.xcconfig" +APS_ENVIRONMENT = development + +// Release.xcconfig +#include "Base.xcconfig" +APS_ENVIRONMENT = production +``` + +Entitlements should include: + +```xml +aps-environment +$(APS_ENVIRONMENT) +``` + +## 2) App Container Setup + +```swift +import SwiftData + +enum AppIdentifiers { + static let appGroupIdentifier = "group.com.example.app" + static let cloudKitContainerIdentifier = "iCloud.com.example.app" +} + +func makeModelContainer(isRunningTests: Bool) throws -> ModelContainer { + let schema = Schema([ + Ritual.self, + RitualArc.self, + ArcHabit.self + ]) + + let storeURL = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)? + .appendingPathComponent("App.sqlite") + ?? URL.documentsDirectory.appendingPathComponent("App.sqlite") + + let config = ModelConfiguration( + schema: schema, + url: storeURL, + cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier) + ) + + return try ModelContainer(for: schema, configurations: [config]) +} +``` + +## 3) Store Pattern (Remote Change + Context Refresh) + +```swift +import Foundation +import Observation +import SwiftData +import CoreData +import os + +@MainActor +@Observable +final class RitualStore { + @ObservationIgnored private let modelContainer: ModelContainer + @ObservationIgnored private var modelContext: ModelContext + @ObservationIgnored private var remoteChangeObserver: NSObjectProtocol? + @ObservationIgnored private let syncLogger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "App", + category: "CloudKitSync" + ) + + private(set) var rituals: [Ritual] = [] + private(set) var dataRefreshVersion: Int = 0 + private(set) var lastRemoteChangeDate: Date? + private(set) var remoteChangeEventCount: Int = 0 + + init(modelContext: ModelContext) { + self.modelContainer = modelContext.container + self.modelContext = modelContext + reloadRituals() + observeRemoteChanges() + } + + deinit { + if let observer = remoteChangeObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + private func observeRemoteChanges() { + syncLogger.info("Starting CloudKit remote change observation") + remoteChangeObserver = NotificationCenter.default.addObserver( + forName: .NSPersistentStoreRemoteChange, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleRemoteStoreChange() + } + } + } + + private func handleRemoteStoreChange() { + remoteChangeEventCount += 1 + lastRemoteChangeDate = Date() + + // Important when long-lived contexts become stale after remote merges. + modelContext = ModelContext(modelContainer) + + syncLogger.info( + "Received remote store change #\(self.remoteChangeEventCount, privacy: .public); reloading" + ) + reloadRituals() + } + + func refresh() { + reloadRituals() + } + + private func reloadRituals() { + do { + rituals = try modelContext.fetch(FetchDescriptor()) + dataRefreshVersion &+= 1 + } catch { + syncLogger.error("Reload failed: \(error.localizedDescription, privacy: .public)") + } + } +} +``` + +## 4) Root View Pattern (Safety-Net Refresh) + +```swift +import SwiftUI + +struct RootView: View { + @Bindable var store: RitualStore + @Environment(\.scenePhase) private var scenePhase + @State private var fallbackRefreshTask: Task? + + var body: some View { + // Ensure body observes remote reload increments. + let _ = store.dataRefreshVersion + + ContentView(store: store) + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + scheduleCloudKitFallbackRefresh() + } else if newPhase == .background { + fallbackRefreshTask?.cancel() + } + } + } + + private func scheduleCloudKitFallbackRefresh() { + let activationDate = Date() + fallbackRefreshTask?.cancel() + 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 } + store.refresh() + } + } +} +``` + +## 5) Verification Script (Manual) + +Run this release gate on two real devices: + +1. Single habit toggle on Device A appears on Device B with both open. +2. Rapid 4-6 toggles on Device A all appear on Device B without pull-to-refresh. +3. Background Device B, then foreground, data remains consistent. +4. Device logs show `CloudKitSync` remote-change events. +5. CloudKit Console private DB shows matching record updates. + +## 6) Common Failure Modes + +- Push works but UI stale: context/view invalidation issue. +- Only updates after relaunch: missing remote observer or stale long-lived context. +- Works in one build flavor only: `aps-environment` mismatch or signing/provisioning drift. +- Partial batch updates shown: render invalidation not happening for every remote merge.