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

This commit is contained in:
Matt Bruce 2026-02-16 14:07:08 -06:00
parent 94e23c946b
commit 3c0637ed7a
3 changed files with 283 additions and 0 deletions

19
PRD.md
View File

@ -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-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-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-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 ### 5.4 Third-Party Dependencies
@ -579,6 +596,7 @@ Andromida/
| `Andromida/Shared/Theme/RitualsTheme.swift` | Bedrock theme configuration | | `Andromida/Shared/Theme/RitualsTheme.swift` | Bedrock theme configuration |
| `Andromida/Shared/BrandingConfig.swift` | Branding constants | | `Andromida/Shared/BrandingConfig.swift` | Branding constants |
| `Andromida/Shared/Configuration/Base.xcconfig` | Build configuration source of truth | | `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/Resources/LaunchScreen.storyboard` | Native launch screen |
| `Andromida/App/State/RitualStore.swift` | Primary data store | | `Andromida/App/State/RitualStore.swift` | Primary data store |
| `Andromida/App/State/SettingsStore.swift` | Settings with cloud sync | | `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.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.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.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 |

View File

@ -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

View File

@ -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
<key>aps-environment</key>
<string>$(APS_ENVIRONMENT)</string>
```
## 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<Ritual>())
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<Void, Never>?
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.