Showing 6 changed files with 125 additions and 3 deletions
+2 -0
HealthProbe/Doc/02-architecture/Implementation-Guide.md
@@ -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:
+2 -1
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -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.
+2 -2
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -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
 
+2 -0
HealthProbe/HealthProbeApp.swift
@@ -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,
+56 -0
HealthProbe/Services/PrototypeStoreResetPolicy.swift
@@ -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
+}
+61 -0
HealthProbeTests/PrototypeStoreResetPolicyTests.swift
@@ -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
+}