Showing 5 changed files with 97 additions and 10 deletions
+6 -0
HealthProbe/Doc/02-architecture/Implementation-Guide.md
@@ -53,6 +53,12 @@ Do not implement one-way migration from the old prototype schema unless a later
53 53
 
54 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 55
 
56
+The manual test reset is scheduled with both a `UserDefaults` flag and a small
57
+marker file in Application Support. Startup reset must treat either signal as
58
+authoritative, delete the archive/cache/prototype stores and SQLite sidecars,
59
+then clear both signals. This keeps benchmark resets reliable even if a test
60
+device is force-closed immediately after scheduling.
61
+
56 62
 ## 3. HealthKit Capture
57 63
 
58 64
 Use:
+1 -0
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -196,6 +196,7 @@ captures.
196 196
 | 2026-06-02 | `ff59257` | Removed unused `samples` indexes on global UUID hash and semantic fingerprint. | Awaiting comparable first-import report. Expected signal is lower `SummedInsertElapsed`; deleted-object lookup remains covered by `(sample_type_id, sample_uuid_hash)`. |
197 197
 | 2026-06-02 | pending | Captured non-chain-start full-scan report after index removal. | Not comparable for first-import performance; reveals a separate full-scan/unchanged-sample write bottleneck. |
198 198
 | 2026-06-02 | pending | Stopped writing `verified` observation events for unchanged existing samples. | Awaiting comparable non-chain-start/full-scan report. Expected signal is lower `SummedInsertElapsed` and especially lower Heart Rate insert time when most rows are unchanged. |
199
+| 2026-06-02 | pending | Fortified scheduled test database reset with a disk marker and extra SQLite sidecar cleanup. | Awaiting real-device confirmation that reset survives force-close/relaunch and produces a clean first-snapshot timeline. |
199 200
 
200 201
 ## Current Diagnosis
201 202
 
+30 -5
HealthProbe/Services/PrototypeStoreResetPolicy.swift
@@ -9,14 +9,32 @@ enum PrototypeStoreResetPolicy {
9 9
     static let currentGeneration = 1
10 10
     static let defaultsKey = "hp_prototypeStoreResetGeneration"
11 11
     static let manualResetDefaultsKey = "hp_prototypeStoreResetScheduled"
12
+    static let manualResetMarkerFileName = ".HealthProbeResetScheduled"
12 13
 
13
-    static func isResetScheduled(defaults: UserDefaults = .standard) -> Bool {
14
-        defaults.bool(forKey: manualResetDefaultsKey) || defaults.integer(forKey: defaultsKey) < currentGeneration
14
+    static func isResetScheduled(
15
+        defaults: UserDefaults = .standard,
16
+        appSupportURL: URL? = nil,
17
+        fileManager: FileManager = .default
18
+    ) -> Bool {
19
+        if defaults.bool(forKey: manualResetDefaultsKey) || defaults.integer(forKey: defaultsKey) < currentGeneration {
20
+            return true
21
+        }
22
+        guard let appSupportURL else { return false }
23
+        return fileManager.fileExists(atPath: manualResetMarkerURL(appSupportURL: appSupportURL).path)
15 24
     }
16 25
 
17
-    static func requestResetOnNextLaunch(defaults: UserDefaults = .standard) {
26
+    static func requestResetOnNextLaunch(
27
+        defaults: UserDefaults = .standard,
28
+        appSupportURL: URL = .applicationSupportDirectory,
29
+        fileManager: FileManager = .default
30
+    ) {
18 31
         defaults.set(true, forKey: manualResetDefaultsKey)
19 32
         defaults.synchronize()
33
+        try? fileManager.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
34
+        try? Data(Date().ISO8601Format().utf8).write(
35
+            to: manualResetMarkerURL(appSupportURL: appSupportURL),
36
+            options: [.atomic]
37
+        )
20 38
     }
21 39
 
22 40
     static func applyIfNeeded(
@@ -24,7 +42,7 @@ enum PrototypeStoreResetPolicy {
24 42
         defaults: UserDefaults = .standard,
25 43
         fileManager: FileManager = .default
26 44
     ) throws -> PrototypeStoreResetResult {
27
-        guard isResetScheduled(defaults: defaults) else {
45
+        guard isResetScheduled(defaults: defaults, appSupportURL: appSupportURL, fileManager: fileManager) else {
28 46
             return PrototypeStoreResetResult(didReset: false, removedURLs: [])
29 47
         }
30 48
 
@@ -38,6 +56,7 @@ enum PrototypeStoreResetPolicy {
38 56
         defaults.set(currentGeneration, forKey: defaultsKey)
39 57
         defaults.set(false, forKey: manualResetDefaultsKey)
40 58
         defaults.synchronize()
59
+        try? fileManager.removeItem(at: manualResetMarkerURL(appSupportURL: appSupportURL))
41 60
         return PrototypeStoreResetResult(didReset: true, removedURLs: removedURLs)
42 61
     }
43 62
 
@@ -60,9 +79,15 @@ enum PrototypeStoreResetPolicy {
60 79
                 baseURL,
61 80
                 appSupportURL.appending(path: "\(baseName)-shm"),
62 81
                 appSupportURL.appending(path: "\(baseName)-wal"),
82
+                appSupportURL.appending(path: "\(baseName)-journal"),
63 83
                 appSupportURL.appending(path: "\(baseName).shm"),
64
-                appSupportURL.appending(path: "\(baseName).wal")
84
+                appSupportURL.appending(path: "\(baseName).wal"),
85
+                appSupportURL.appending(path: "\(baseName).journal")
65 86
             ]
66 87
         }
67 88
     }
89
+
90
+    private static func manualResetMarkerURL(appSupportURL: URL) -> URL {
91
+        appSupportURL.appending(path: manualResetMarkerFileName)
92
+    }
68 93
 }
+2 -2
HealthProbe/Views/Settings/SettingsView.swift
@@ -9,7 +9,7 @@ struct SettingsView: View {
9 9
     @State private var archiveObservationCount: Int?
10 10
     @State private var timeoutProfiles: [LocalMetricTimeoutProfile] = []
11 11
     @State private var currentDeviceProfile: LocalDeviceProfile?
12
-    @State private var resetScheduled = PrototypeStoreResetPolicy.isResetScheduled()
12
+    @State private var resetScheduled = PrototypeStoreResetPolicy.isResetScheduled(appSupportURL: .applicationSupportDirectory)
13 13
 
14 14
     private var currentDeviceID: String {
15 15
         AppSettings.currentDeviceID
@@ -283,7 +283,7 @@ struct SettingsView: View {
283 283
     }
284 284
 
285 285
     private func loadArchiveCacheStatus() {
286
-        resetScheduled = PrototypeStoreResetPolicy.isResetScheduled()
286
+        resetScheduled = PrototypeStoreResetPolicy.isResetScheduled(appSupportURL: .applicationSupportDirectory)
287 287
         do {
288 288
             archiveObservationCount = try CoreDataArchiveCacheStore().observationCount()
289 289
         } catch {
+58 -3
HealthProbeTests/PrototypeStoreResetPolicyTests.swift
@@ -64,7 +64,10 @@ final class PrototypeStoreResetPolicyTests: XCTestCase {
64 64
 
65 65
         XCTAssertFalse(PrototypeStoreResetPolicy.isResetScheduled(defaults: defaults))
66 66
 
67
-        PrototypeStoreResetPolicy.requestResetOnNextLaunch(defaults: defaults)
67
+        PrototypeStoreResetPolicy.requestResetOnNextLaunch(
68
+            defaults: defaults,
69
+            appSupportURL: temporaryDirectory
70
+        )
68 71
 
69 72
         XCTAssertTrue(PrototypeStoreResetPolicy.isResetScheduled(defaults: defaults))
70 73
     }
@@ -73,10 +76,15 @@ final class PrototypeStoreResetPolicyTests: XCTestCase {
73 76
         defaults.set(PrototypeStoreResetPolicy.currentGeneration, forKey: PrototypeStoreResetPolicy.defaultsKey)
74 77
         let archiveURL = temporaryDirectory.appending(path: "HealthProbeArchive.sqlite")
75 78
         let cacheWALURL = temporaryDirectory.appending(path: "HealthProbeCache.sqlite-wal")
79
+        let archiveJournalURL = temporaryDirectory.appending(path: "HealthProbeArchive.sqlite-journal")
76 80
         try Data("archive".utf8).write(to: archiveURL)
77 81
         try Data("cache".utf8).write(to: cacheWALURL)
82
+        try Data("journal".utf8).write(to: archiveJournalURL)
78 83
 
79
-        PrototypeStoreResetPolicy.requestResetOnNextLaunch(defaults: defaults)
84
+        PrototypeStoreResetPolicy.requestResetOnNextLaunch(
85
+            defaults: defaults,
86
+            appSupportURL: temporaryDirectory
87
+        )
80 88
 
81 89
         let result = try PrototypeStoreResetPolicy.applyIfNeeded(
82 90
             appSupportURL: temporaryDirectory,
@@ -86,19 +94,66 @@ final class PrototypeStoreResetPolicyTests: XCTestCase {
86 94
         XCTAssertTrue(result.didReset)
87 95
         XCTAssertEqual(Set(result.removedURLs.map(\.lastPathComponent)), Set([
88 96
             "HealthProbeArchive.sqlite",
97
+            "HealthProbeArchive.sqlite-journal",
89 98
             "HealthProbeCache.sqlite-wal"
90 99
         ]))
91 100
         XCTAssertFalse(FileManager.default.fileExists(atPath: archiveURL.path))
92 101
         XCTAssertFalse(FileManager.default.fileExists(atPath: cacheWALURL.path))
102
+        XCTAssertFalse(FileManager.default.fileExists(atPath: archiveJournalURL.path))
93 103
         XCTAssertFalse(PrototypeStoreResetPolicy.isResetScheduled(defaults: defaults))
94 104
     }
95 105
 
96 106
     func testManualResetFlagSurvivesWithoutChangingGenerationKey() throws {
97 107
         defaults.set(PrototypeStoreResetPolicy.currentGeneration, forKey: PrototypeStoreResetPolicy.defaultsKey)
98 108
 
99
-        PrototypeStoreResetPolicy.requestResetOnNextLaunch(defaults: defaults)
109
+        PrototypeStoreResetPolicy.requestResetOnNextLaunch(
110
+            defaults: defaults,
111
+            appSupportURL: temporaryDirectory
112
+        )
100 113
 
101 114
         XCTAssertEqual(defaults.integer(forKey: PrototypeStoreResetPolicy.defaultsKey), PrototypeStoreResetPolicy.currentGeneration)
102 115
         XCTAssertTrue(defaults.bool(forKey: PrototypeStoreResetPolicy.manualResetDefaultsKey))
103 116
     }
117
+
118
+    func testRequestResetOnNextLaunchWritesDiskMarker() throws {
119
+        defaults.set(PrototypeStoreResetPolicy.currentGeneration, forKey: PrototypeStoreResetPolicy.defaultsKey)
120
+
121
+        PrototypeStoreResetPolicy.requestResetOnNextLaunch(
122
+            defaults: defaults,
123
+            appSupportURL: temporaryDirectory
124
+        )
125
+
126
+        XCTAssertTrue(PrototypeStoreResetPolicy.isResetScheduled(
127
+            defaults: defaults,
128
+            appSupportURL: temporaryDirectory
129
+        ))
130
+        XCTAssertTrue(FileManager.default.fileExists(
131
+            atPath: temporaryDirectory
132
+                .appending(path: PrototypeStoreResetPolicy.manualResetMarkerFileName)
133
+                .path
134
+        ))
135
+    }
136
+
137
+    func testDiskMarkerTriggersResetWhenDefaultsFlagIsMissing() throws {
138
+        defaults.set(PrototypeStoreResetPolicy.currentGeneration, forKey: PrototypeStoreResetPolicy.defaultsKey)
139
+        defaults.set(false, forKey: PrototypeStoreResetPolicy.manualResetDefaultsKey)
140
+        let archiveURL = temporaryDirectory.appending(path: "HealthProbeArchive.sqlite")
141
+        let markerURL = temporaryDirectory.appending(path: PrototypeStoreResetPolicy.manualResetMarkerFileName)
142
+        try Data("archive".utf8).write(to: archiveURL)
143
+        try Data("pending".utf8).write(to: markerURL)
144
+
145
+        let result = try PrototypeStoreResetPolicy.applyIfNeeded(
146
+            appSupportURL: temporaryDirectory,
147
+            defaults: defaults
148
+        )
149
+
150
+        XCTAssertTrue(result.didReset)
151
+        XCTAssertEqual(result.removedURLs.map(\.lastPathComponent), ["HealthProbeArchive.sqlite"])
152
+        XCTAssertFalse(FileManager.default.fileExists(atPath: archiveURL.path))
153
+        XCTAssertFalse(FileManager.default.fileExists(atPath: markerURL.path))
154
+        XCTAssertFalse(PrototypeStoreResetPolicy.isResetScheduled(
155
+            defaults: defaults,
156
+            appSupportURL: temporaryDirectory
157
+        ))
158
+    }
104 159
 }