@@ -51,6 +51,8 @@ Archive v2 startup behavior: |
||
| 51 | 51 |
|
| 52 | 52 |
Do not implement one-way migration from the old prototype schema unless a later dated product decision reverses this policy. |
| 53 | 53 |
|
| 54 |
+During the SwiftData-to-archive-v2 transition, legacy SwiftData snapshots must not be mixed with new SQLite archive observations. A SwiftData snapshot that predates archive v2 can still produce a UI diff, but its historical records may not exist in the archive and therefore cannot be exported or used as backup evidence. Test builds may destructively reset `HealthProbeRecords.store`, `HealthProbeArchive.sqlite`, and `HealthProbeCache.sqlite` once for this transition. Local settings stores can be preserved. |
|
| 55 |
+ |
|
| 54 | 56 |
## 3. HealthKit Capture |
| 55 | 57 |
|
| 56 | 58 |
Use: |
@@ -27,7 +27,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 27 | 27 |
| HealthKit capture | Prototype exists | Adapt capture to write differential SQLite observations first | |
| 28 | 28 |
| SQLite archive | Archive v2 schema, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed | Start Core Data cache work | |
| 29 | 29 |
| Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/health rows, and Dashboard archive-cache status wiring are in place | Move Snapshots/Data Types to cache DTOs and add targeted partial invalidation | |
| 30 |
-| SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition | |
|
| 30 |
+| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations | Treat as disposable prototype data; reset/ignore during v2 transition | |
|
| 31 | 31 |
| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker, and record-change detail uses a separate preview/paged list view with archive-value enrichment and scoped export action | Reframe remaining screens around observations, diffs, export, archive status | |
| 32 | 32 |
| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications | |
| 33 | 33 |
| Export | Prototype scoped JSON export exists | Add recovery-compatible manifests and streaming/paged export | |
@@ -50,6 +50,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 50 | 50 |
- Existing `Anomaly*` model/service names are legacy language. |
| 51 | 51 |
- Some screens still imply snapshot-count monitoring rather than Time Machine inspection. |
| 52 | 52 |
- Current UI/cache layers still depend on SwiftData prototype models. |
| 53 |
+- Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated. |
|
| 53 | 54 |
- Existing implementation may decode or cache too much data for low-end devices. |
| 54 | 55 |
- Old prototype database compatibility is no longer required. |
| 55 | 56 |
- Initial SQLite archive tests cover open/init/reset/idempotency, legacy mirror removal, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, and consolidation-evidence labels, but not yet export behavior. |
@@ -242,7 +242,7 @@ Checklist: |
||
| 242 | 242 |
- [ ] Identify all remaining SwiftData imports. |
| 243 | 243 |
- [ ] Replace SwiftData models used by active flows. |
| 244 | 244 |
- [ ] Remove/disable `ModelContainer` as required for target builds. |
| 245 |
-- [ ] Add prototype-store ignore/delete/reset path for test installs. |
|
| 245 |
+- [x] Add prototype-store ignore/delete/reset path for test installs. |
|
| 246 | 246 |
- [ ] Verify no old-store compatibility layer remains in active flows. |
| 247 | 247 |
- [ ] Lower deployment target as far as dependencies allow. |
| 248 | 248 |
- [ ] Verify build for iOS 15-era target constraints. |
@@ -250,7 +250,7 @@ Checklist: |
||
| 250 | 250 |
Acceptance: |
| 251 | 251 |
- [ ] SwiftData is not required for normal app launch. |
| 252 | 252 |
- [ ] Active flows use SQLite + Core Data cache. |
| 253 |
-- [ ] Prototype data handling is explicit: old stores are ignored/deleted/reset for test installs. |
|
| 253 |
+- [x] Prototype data handling is explicit: old stores are ignored/deleted/reset for test installs. |
|
| 254 | 254 |
|
| 255 | 255 |
## Milestone 10 - Acceptance Gate |
| 256 | 256 |
|
@@ -28,6 +28,8 @@ struct HealthProbeApp: App {
|
||
| 28 | 28 |
// SwiftData is not the forensic source of truth. Complete HealthKit samples |
| 29 | 29 |
// belong in the local archive store; these rows must remain rebuildable. |
| 30 | 30 |
private static func createModelContainer() throws -> ModelContainer {
|
| 31 |
+ _ = try PrototypeStoreResetPolicy.applyIfNeeded() |
|
| 32 |
+ |
|
| 31 | 33 |
let fullSchema = Schema([ |
| 32 | 34 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, |
| 33 | 35 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
@@ -0,0 +1,56 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+ |
|
| 3 |
+struct PrototypeStoreResetResult: Equatable {
|
|
| 4 |
+ let didReset: Bool |
|
| 5 |
+ let removedURLs: [URL] |
|
| 6 |
+} |
|
| 7 |
+ |
|
| 8 |
+enum PrototypeStoreResetPolicy {
|
|
| 9 |
+ static let currentGeneration = 1 |
|
| 10 |
+ static let defaultsKey = "hp_prototypeStoreResetGeneration" |
|
| 11 |
+ |
|
| 12 |
+ static func applyIfNeeded( |
|
| 13 |
+ appSupportURL: URL = .applicationSupportDirectory, |
|
| 14 |
+ defaults: UserDefaults = .standard, |
|
| 15 |
+ fileManager: FileManager = .default |
|
| 16 |
+ ) throws -> PrototypeStoreResetResult {
|
|
| 17 |
+ guard defaults.integer(forKey: defaultsKey) < currentGeneration else {
|
|
| 18 |
+ return PrototypeStoreResetResult(didReset: false, removedURLs: []) |
|
| 19 |
+ } |
|
| 20 |
+ |
|
| 21 |
+ var removedURLs: [URL] = [] |
|
| 22 |
+ for url in destructiveResetURLs(appSupportURL: appSupportURL) {
|
|
| 23 |
+ guard fileManager.fileExists(atPath: url.path) else { continue }
|
|
| 24 |
+ try fileManager.removeItem(at: url) |
|
| 25 |
+ removedURLs.append(url) |
|
| 26 |
+ } |
|
| 27 |
+ |
|
| 28 |
+ defaults.set(currentGeneration, forKey: defaultsKey) |
|
| 29 |
+ return PrototypeStoreResetResult(didReset: true, removedURLs: removedURLs) |
|
| 30 |
+ } |
|
| 31 |
+ |
|
| 32 |
+ static func destructiveResetURLs(appSupportURL: URL) -> [URL] {
|
|
| 33 |
+ sidecarURLs( |
|
| 34 |
+ forBaseNames: [ |
|
| 35 |
+ "HealthProbeRecords.store", |
|
| 36 |
+ "HealthProbeCloud.store", |
|
| 37 |
+ "HealthProbeArchive.sqlite", |
|
| 38 |
+ "HealthProbeCache.sqlite" |
|
| 39 |
+ ], |
|
| 40 |
+ appSupportURL: appSupportURL |
|
| 41 |
+ ) |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ private static func sidecarURLs(forBaseNames baseNames: [String], appSupportURL: URL) -> [URL] {
|
|
| 45 |
+ baseNames.flatMap { baseName in
|
|
| 46 |
+ let baseURL = appSupportURL.appending(path: baseName) |
|
| 47 |
+ return [ |
|
| 48 |
+ baseURL, |
|
| 49 |
+ appSupportURL.appending(path: "\(baseName)-shm"), |
|
| 50 |
+ appSupportURL.appending(path: "\(baseName)-wal"), |
|
| 51 |
+ appSupportURL.appending(path: "\(baseName).shm"), |
|
| 52 |
+ appSupportURL.appending(path: "\(baseName).wal") |
|
| 53 |
+ ] |
|
| 54 |
+ } |
|
| 55 |
+ } |
|
| 56 |
+} |
|
@@ -0,0 +1,61 @@ |
||
| 1 |
+import XCTest |
|
| 2 |
+@testable import HealthProbe |
|
| 3 |
+ |
|
| 4 |
+final class PrototypeStoreResetPolicyTests: XCTestCase {
|
|
| 5 |
+ private var temporaryDirectory: URL! |
|
| 6 |
+ private var defaults: UserDefaults! |
|
| 7 |
+ private var defaultsSuiteName: String! |
|
| 8 |
+ |
|
| 9 |
+ override func setUpWithError() throws {
|
|
| 10 |
+ temporaryDirectory = FileManager.default.temporaryDirectory |
|
| 11 |
+ .appending(path: "HealthProbeResetTests-\(UUID().uuidString)", directoryHint: .isDirectory) |
|
| 12 |
+ try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) |
|
| 13 |
+ defaultsSuiteName = "HealthProbeResetTests-\(UUID().uuidString)" |
|
| 14 |
+ defaults = UserDefaults(suiteName: defaultsSuiteName) |
|
| 15 |
+ defaults.removePersistentDomain(forName: defaultsSuiteName) |
|
| 16 |
+ } |
|
| 17 |
+ |
|
| 18 |
+ override func tearDownWithError() throws {
|
|
| 19 |
+ if let temporaryDirectory {
|
|
| 20 |
+ try? FileManager.default.removeItem(at: temporaryDirectory) |
|
| 21 |
+ } |
|
| 22 |
+ if let defaults {
|
|
| 23 |
+ defaults.removePersistentDomain(forName: defaultsSuiteName) |
|
| 24 |
+ } |
|
| 25 |
+ temporaryDirectory = nil |
|
| 26 |
+ defaults = nil |
|
| 27 |
+ defaultsSuiteName = nil |
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 30 |
+ func testApplyIfNeededRemovesPrototypeArchiveAndCacheStoresOnce() throws {
|
|
| 31 |
+ let files = [ |
|
| 32 |
+ "HealthProbeRecords.store", |
|
| 33 |
+ "HealthProbeRecords.store-wal", |
|
| 34 |
+ "HealthProbeArchive.sqlite", |
|
| 35 |
+ "HealthProbeArchive.sqlite-shm", |
|
| 36 |
+ "HealthProbeCache.sqlite" |
|
| 37 |
+ ] |
|
| 38 |
+ for file in files {
|
|
| 39 |
+ try Data("prototype".utf8).write(to: temporaryDirectory.appending(path: file))
|
|
| 40 |
+ } |
|
| 41 |
+ try Data("settings".utf8).write(to: temporaryDirectory.appending(path: "HealthProbeLocal.store"))
|
|
| 42 |
+ |
|
| 43 |
+ let first = try PrototypeStoreResetPolicy.applyIfNeeded( |
|
| 44 |
+ appSupportURL: temporaryDirectory, |
|
| 45 |
+ defaults: defaults |
|
| 46 |
+ ) |
|
| 47 |
+ XCTAssertTrue(first.didReset) |
|
| 48 |
+ XCTAssertEqual(Set(first.removedURLs.map(\.lastPathComponent)), Set(files)) |
|
| 49 |
+ for file in files {
|
|
| 50 |
+ XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryDirectory.appending(path: file).path)) |
|
| 51 |
+ } |
|
| 52 |
+ XCTAssertTrue(FileManager.default.fileExists(atPath: temporaryDirectory.appending(path: "HealthProbeLocal.store").path)) |
|
| 53 |
+ |
|
| 54 |
+ let second = try PrototypeStoreResetPolicy.applyIfNeeded( |
|
| 55 |
+ appSupportURL: temporaryDirectory, |
|
| 56 |
+ defaults: defaults |
|
| 57 |
+ ) |
|
| 58 |
+ XCTAssertFalse(second.didReset) |
|
| 59 |
+ XCTAssertTrue(second.removedURLs.isEmpty) |
|
| 60 |
+ } |
|
| 61 |
+} |
|