Showing 7 changed files with 380 additions and 25 deletions
+138 -0
HealthProbe.xcodeproj/project.pbxproj
@@ -8,8 +8,19 @@
8 8
 
9 9
 /* Begin PBXFileReference section */
10 10
 		439832792FA4933E003C0182 /* HealthProbe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HealthProbe.app; sourceTree = BUILT_PRODUCTS_DIR; };
11
+		43A100012FA5000000000001 /* HealthProbeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HealthProbeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
11 12
 /* End PBXFileReference section */
12 13
 
14
+/* Begin PBXContainerItemProxy section */
15
+		43A1000B2FA5000000000001 /* PBXContainerItemProxy */ = {
16
+			isa = PBXContainerItemProxy;
17
+			containerPortal = 439832712FA4933E003C0182 /* Project object */;
18
+			proxyType = 1;
19
+			remoteGlobalIDString = 439832782FA4933E003C0182;
20
+			remoteInfo = HealthProbe;
21
+		};
22
+/* End PBXContainerItemProxy section */
23
+
13 24
 /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
14 25
 		439832862FA4933F003C0182 /* Exceptions for "HealthProbe" folder in "HealthProbe" target */ = {
15 26
 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
@@ -44,6 +55,11 @@
44 55
 			path = HealthProbe;
45 56
 			sourceTree = "<group>";
46 57
 		};
58
+		43A100022FA5000000000001 /* HealthProbeTests */ = {
59
+			isa = PBXFileSystemSynchronizedRootGroup;
60
+			path = HealthProbeTests;
61
+			sourceTree = "<group>";
62
+		};
47 63
 /* End PBXFileSystemSynchronizedRootGroup section */
48 64
 
49 65
 /* Begin PBXFrameworksBuildPhase section */
@@ -54,6 +70,13 @@
54 70
 			);
55 71
 			runOnlyForDeploymentPostprocessing = 0;
56 72
 		};
73
+		43A100042FA5000000000001 /* Frameworks */ = {
74
+			isa = PBXFrameworksBuildPhase;
75
+			buildActionMask = 2147483647;
76
+			files = (
77
+			);
78
+			runOnlyForDeploymentPostprocessing = 0;
79
+		};
57 80
 /* End PBXFrameworksBuildPhase section */
58 81
 
59 82
 /* Begin PBXGroup section */
@@ -61,6 +84,7 @@
61 84
 			isa = PBXGroup;
62 85
 			children = (
63 86
 				4398327B2FA4933E003C0182 /* HealthProbe */,
87
+				43A100022FA5000000000001 /* HealthProbeTests */,
64 88
 				4398327A2FA4933E003C0182 /* Products */,
65 89
 			);
66 90
 			sourceTree = "<group>";
@@ -69,6 +93,7 @@
69 93
 			isa = PBXGroup;
70 94
 			children = (
71 95
 				439832792FA4933E003C0182 /* HealthProbe.app */,
96
+				43A100012FA5000000000001 /* HealthProbeTests.xctest */,
72 97
 			);
73 98
 			name = Products;
74 99
 			sourceTree = "<group>";
@@ -98,6 +123,29 @@
98 123
 			productReference = 439832792FA4933E003C0182 /* HealthProbe.app */;
99 124
 			productType = "com.apple.product-type.application";
100 125
 		};
126
+		43A100062FA5000000000001 /* HealthProbeTests */ = {
127
+			isa = PBXNativeTarget;
128
+			buildConfigurationList = 43A100092FA5000000000001 /* Build configuration list for PBXNativeTarget "HealthProbeTests" */;
129
+			buildPhases = (
130
+				43A100032FA5000000000001 /* Sources */,
131
+				43A100042FA5000000000001 /* Frameworks */,
132
+				43A100052FA5000000000001 /* Resources */,
133
+			);
134
+			buildRules = (
135
+			);
136
+			dependencies = (
137
+				43A1000A2FA5000000000001 /* PBXTargetDependency */,
138
+			);
139
+			fileSystemSynchronizedGroups = (
140
+				43A100022FA5000000000001 /* HealthProbeTests */,
141
+			);
142
+			name = HealthProbeTests;
143
+			packageProductDependencies = (
144
+			);
145
+			productName = HealthProbeTests;
146
+			productReference = 43A100012FA5000000000001 /* HealthProbeTests.xctest */;
147
+			productType = "com.apple.product-type.bundle.unit-test";
148
+		};
101 149
 /* End PBXNativeTarget section */
102 150
 
103 151
 /* Begin PBXProject section */
@@ -116,6 +164,10 @@
116 164
 							};
117 165
 						};
118 166
 					};
167
+					43A100062FA5000000000001 = {
168
+						CreatedOnToolsVersion = 26.4.1;
169
+						TestTargetID = 439832782FA4933E003C0182;
170
+					};
119 171
 				};
120 172
 			};
121 173
 			buildConfigurationList = 439832742FA4933E003C0182 /* Build configuration list for PBXProject "HealthProbe" */;
@@ -133,6 +185,7 @@
133 185
 			projectRoot = "";
134 186
 			targets = (
135 187
 				439832782FA4933E003C0182 /* HealthProbe */,
188
+				43A100062FA5000000000001 /* HealthProbeTests */,
136 189
 			);
137 190
 		};
138 191
 /* End PBXProject section */
@@ -145,6 +198,13 @@
145 198
 			);
146 199
 			runOnlyForDeploymentPostprocessing = 0;
147 200
 		};
201
+		43A100052FA5000000000001 /* Resources */ = {
202
+			isa = PBXResourcesBuildPhase;
203
+			buildActionMask = 2147483647;
204
+			files = (
205
+			);
206
+			runOnlyForDeploymentPostprocessing = 0;
207
+		};
148 208
 /* End PBXResourcesBuildPhase section */
149 209
 
150 210
 /* Begin PBXSourcesBuildPhase section */
@@ -155,8 +215,23 @@
155 215
 			);
156 216
 			runOnlyForDeploymentPostprocessing = 0;
157 217
 		};
218
+		43A100032FA5000000000001 /* Sources */ = {
219
+			isa = PBXSourcesBuildPhase;
220
+			buildActionMask = 2147483647;
221
+			files = (
222
+			);
223
+			runOnlyForDeploymentPostprocessing = 0;
224
+		};
158 225
 /* End PBXSourcesBuildPhase section */
159 226
 
227
+/* Begin PBXTargetDependency section */
228
+		43A1000A2FA5000000000001 /* PBXTargetDependency */ = {
229
+			isa = PBXTargetDependency;
230
+			target = 439832782FA4933E003C0182 /* HealthProbe */;
231
+			targetProxy = 43A1000B2FA5000000000001 /* PBXContainerItemProxy */;
232
+		};
233
+/* End PBXTargetDependency section */
234
+
160 235
 /* Begin XCBuildConfiguration section */
161 236
 		439832882FA4933F003C0182 /* Debug */ = {
162 237
 			isa = XCBuildConfiguration;
@@ -234,6 +309,60 @@
234 309
 			};
235 310
 			name = Release;
236 311
 		};
312
+		43A100072FA5000000000001 /* Debug */ = {
313
+			isa = XCBuildConfiguration;
314
+			buildSettings = {
315
+				BUNDLE_LOADER = "$(TEST_HOST)";
316
+				CODE_SIGN_STYLE = Automatic;
317
+				CURRENT_PROJECT_VERSION = 1;
318
+				DEVELOPMENT_TEAM = 9K2U3V9GZF;
319
+				GENERATE_INFOPLIST_FILE = YES;
320
+				IPHONEOS_DEPLOYMENT_TARGET = 26.4;
321
+				LD_RUNPATH_SEARCH_PATHS = (
322
+					"$(inherited)",
323
+					"@executable_path/Frameworks",
324
+					"@loader_path/Frameworks",
325
+				);
326
+				MARKETING_VERSION = 1.0;
327
+				PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests;
328
+				PRODUCT_NAME = "$(TARGET_NAME)";
329
+				SDKROOT = iphoneos;
330
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
331
+				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
332
+				SWIFT_VERSION = 5.0;
333
+				TARGETED_DEVICE_FAMILY = 1;
334
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe";
335
+				TEST_TARGET_NAME = HealthProbe;
336
+			};
337
+			name = Debug;
338
+		};
339
+		43A100082FA5000000000001 /* Release */ = {
340
+			isa = XCBuildConfiguration;
341
+			buildSettings = {
342
+				BUNDLE_LOADER = "$(TEST_HOST)";
343
+				CODE_SIGN_STYLE = Automatic;
344
+				CURRENT_PROJECT_VERSION = 1;
345
+				DEVELOPMENT_TEAM = 9K2U3V9GZF;
346
+				GENERATE_INFOPLIST_FILE = YES;
347
+				IPHONEOS_DEPLOYMENT_TARGET = 26.4;
348
+				LD_RUNPATH_SEARCH_PATHS = (
349
+					"$(inherited)",
350
+					"@executable_path/Frameworks",
351
+					"@loader_path/Frameworks",
352
+				);
353
+				MARKETING_VERSION = 1.0;
354
+				PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests;
355
+				PRODUCT_NAME = "$(TARGET_NAME)";
356
+				SDKROOT = iphoneos;
357
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
358
+				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
359
+				SWIFT_VERSION = 5.0;
360
+				TARGETED_DEVICE_FAMILY = 1;
361
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe";
362
+				TEST_TARGET_NAME = HealthProbe;
363
+			};
364
+			name = Release;
365
+		};
237 366
 		4398328A2FA4933F003C0182 /* Debug */ = {
238 367
 			isa = XCBuildConfiguration;
239 368
 			buildSettings = {
@@ -378,6 +507,15 @@
378 507
 			defaultConfigurationIsVisible = 0;
379 508
 			defaultConfigurationName = Release;
380 509
 		};
510
+		43A100092FA5000000000001 /* Build configuration list for PBXNativeTarget "HealthProbeTests" */ = {
511
+			isa = XCConfigurationList;
512
+			buildConfigurations = (
513
+				43A100072FA5000000000001 /* Debug */,
514
+				43A100082FA5000000000001 /* Release */,
515
+			);
516
+			defaultConfigurationIsVisible = 0;
517
+			defaultConfigurationName = Release;
518
+		};
381 519
 /* End XCConfigurationList section */
382 520
 	};
383 521
 	rootObject = 439832712FA4933E003C0182 /* Project object */;
+27 -0
HealthProbe.xcodeproj/xcshareddata/xcschemes/HealthProbe.xcscheme
@@ -21,6 +21,20 @@
21 21
                ReferencedContainer = "container:HealthProbe.xcodeproj">
22 22
             </BuildableReference>
23 23
          </BuildActionEntry>
24
+         <BuildActionEntry
25
+            buildForTesting = "YES"
26
+            buildForRunning = "NO"
27
+            buildForProfiling = "NO"
28
+            buildForArchiving = "NO"
29
+            buildForAnalyzing = "NO">
30
+            <BuildableReference
31
+               BuildableIdentifier = "primary"
32
+               BlueprintIdentifier = "43A100062FA5000000000001"
33
+               BuildableName = "HealthProbeTests.xctest"
34
+               BlueprintName = "HealthProbeTests"
35
+               ReferencedContainer = "container:HealthProbe.xcodeproj">
36
+            </BuildableReference>
37
+         </BuildActionEntry>
24 38
       </BuildActionEntries>
25 39
    </BuildAction>
26 40
    <TestAction
@@ -29,6 +43,19 @@
29 43
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30 44
       shouldUseLaunchSchemeArgsEnv = "YES"
31 45
       shouldAutocreateTestPlan = "YES">
46
+      <Testables>
47
+         <TestableReference
48
+            skipped = "NO"
49
+            parallelizable = "YES">
50
+            <BuildableReference
51
+               BuildableIdentifier = "primary"
52
+               BlueprintIdentifier = "43A100062FA5000000000001"
53
+               BuildableName = "HealthProbeTests.xctest"
54
+               BlueprintName = "HealthProbeTests"
55
+               ReferencedContainer = "container:HealthProbe.xcodeproj">
56
+            </BuildableReference>
57
+         </TestableReference>
58
+      </Testables>
32 59
    </TestAction>
33 60
    <LaunchAction
34 61
       buildConfiguration = "Debug"
+1 -1
HealthProbe/Doc/00-agent-guides/AGENTS.md
@@ -338,4 +338,4 @@ When one agent needs to communicate a decision or change to another:
338 338
 | UI – Snapshots + Detail | ✅ Done | Claude Code |
339 339
 | UI – Data Types | ✅ Done | Claude Code |
340 340
 | UI – Settings | ✅ Done | Claude Code |
341
-| Unit Tests | ⏳ Not started | Tests agent |
341
+| Unit Tests | ⏳ Started: SQLite archive init/reset/idempotency tests | Tests agent |
+9 -9
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -25,7 +25,7 @@ There are no real deployments, only test installations. Existing prototype datab
25 25
 |------|----------------|--------------------|
26 26
 | Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index |
27 27
 | HealthKit capture | Prototype exists | Adapt capture to write differential SQLite observations first |
28
-| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, and v2 record reads are partially implemented; legacy write mirror still exists | Add diff SQL, open/schema-version tests, idempotency tests, then retire `archive_samples` |
28
+| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, and initial XCTest coverage are in place; legacy write mirror still exists | Add SQL diff/count queries, large synthetic-data tests, then retire `archive_samples` |
29 29
 | Core Data cache | Not implemented | Add rebuildable cache for expensive counts, summaries, report metadata, UI state |
30 30
 | SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition |
31 31
 | UI | Prototype exists | Reframe screens around observations, diffs, export, archive status |
@@ -38,14 +38,13 @@ There are no real deployments, only test installations. Existing prototype datab
38 38
 
39 39
 Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.md).
40 40
 
41
-1. Finish differential write path hardening with retry/idempotency tests.
42
-2. Add SQLite integrity/open/schema-version tests.
43
-3. Move large diffs/counts into SQL queries with indexes/temp tables/paged results.
44
-4. Add Core Data UI/report cache and rebuild pipeline.
45
-5. Replace SwiftData UI dependencies with Core Data/cache DTOs.
46
-6. Update UI language from anomaly/status to observation/diff/export.
47
-7. Add streaming exports with manifests.
48
-8. Validate on low-memory/legacy-class devices.
41
+1. Move large diffs/counts into SQL queries with indexes/temp tables/paged results.
42
+2. Expand the synthetic large-data test harness for diff/export memory behavior.
43
+3. Add Core Data UI/report cache and rebuild pipeline.
44
+4. Replace SwiftData UI dependencies with Core Data/cache DTOs.
45
+5. Update UI language from anomaly/status to observation/diff/export.
46
+6. Add streaming exports with manifests.
47
+7. Validate on low-memory/legacy-class devices.
49 48
 
50 49
 ## Known Prototype Mismatches
51 50
 
@@ -55,6 +54,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
55 54
 - Current archive schema is not sufficient as the long-term source of truth.
56 55
 - Existing implementation may decode or cache too much data for low-end devices.
57 56
 - Old prototype database compatibility is no longer required.
57
+- Initial SQLite archive tests cover open/init/reset/idempotency, but not yet large-volume diff/export behavior.
58 58
 
59 59
 ## Verification Checklist
60 60
 
+5 -5
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -103,15 +103,15 @@ Checklist:
103 103
 - [x] Implement `export_items`.
104 104
 - [x] Add required indexes.
105 105
 - [x] Add archive integrity report for schema version, required tables, `PRAGMA integrity_check`, and foreign keys.
106
-- [ ] Add SQLite integrity/open/schema-version tests.
106
+- [x] Add SQLite integrity/open/schema-version tests.
107 107
 
108 108
 Acceptance:
109
-- [ ] Fresh archive initializes successfully.
109
+- [x] Fresh archive initializes successfully.
110 110
 - [x] Schema version is recorded.
111 111
 - [x] Archive v2 can initialize after old prototype stores are removed or ignored.
112
-- [ ] `PRAGMA integrity_check` passes.
112
+- [x] `PRAGMA integrity_check` passes.
113 113
 - [x] Required indexes exist.
114
-- [ ] Empty archive queries return valid empty results.
114
+- [x] Empty archive queries return valid empty results.
115 115
 
116 116
 ## Milestone 4 - Differential Write Path
117 117
 
@@ -129,7 +129,7 @@ Checklist:
129 129
 - [x] Maintain open visibility ranges for visible samples.
130 130
 - [x] Rebuild/update affected type summaries and daily aggregates after capture/delete observations.
131 131
 - [x] Commit SQLite before Core Data/cache work.
132
-- [ ] Make repeated capture page writes idempotent.
132
+- [x] Make repeated capture page writes idempotent.
133 133
 
134 134
 Acceptance:
135 135
 - [x] Initial import stores identities and versions once.
+35 -10
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -1010,8 +1010,18 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
1010 1010
         guard row.sourceName != nil || row.sourceBundleIdentifier != nil else { return nil }
1011 1011
         let sourceNameHash = row.sourceName.map { HashService.archiveContentHash(domain: "hp:v2:source_name", parts: [$0]) }
1012 1012
         let sourceID = try upsertSource(sourceNameHash: sourceNameHash, bundleIdentifier: row.sourceBundleIdentifier, db: db)
1013
+        if let existing = try sourceRevisionID(
1014
+            sourceID: sourceID,
1015
+            productType: row.sourceProductType,
1016
+            version: row.sourceVersion,
1017
+            operatingSystemVersion: row.sourceOperatingSystemVersion,
1018
+            db: db
1019
+        ) {
1020
+            return existing
1021
+        }
1022
+
1013 1023
         try withStatement(
1014
-            "INSERT OR IGNORE INTO source_revisions (source_id, product_type, version, operating_system_version) VALUES (?, ?, ?, ?)",
1024
+            "INSERT INTO source_revisions (source_id, product_type, version, operating_system_version) VALUES (?, ?, ?, ?)",
1015 1025
             db: db
1016 1026
         ) { statement in
1017 1027
             bindInt64(sourceID, to: 1, in: statement)
@@ -1022,7 +1032,17 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
1022 1032
                 throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1023 1033
             }
1024 1034
         }
1025
-        return try requiredInt64(
1035
+        return sqlite3_last_insert_rowid(db)
1036
+    }
1037
+
1038
+    private func sourceRevisionID(
1039
+        sourceID: Int64,
1040
+        productType: String?,
1041
+        version: String?,
1042
+        operatingSystemVersion: String?,
1043
+        db: OpaquePointer?
1044
+    ) throws -> Int64? {
1045
+        try optionalInt64(
1026 1046
             """
1027 1047
             SELECT id FROM source_revisions
1028 1048
             WHERE source_id = ?
@@ -1034,12 +1054,12 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
1034 1054
             db: db
1035 1055
         ) { statement in
1036 1056
             bindInt64(sourceID, to: 1, in: statement)
1037
-            bindText(row.sourceProductType, to: 2, in: statement)
1038
-            bindText(row.sourceProductType, to: 3, in: statement)
1039
-            bindText(row.sourceVersion, to: 4, in: statement)
1040
-            bindText(row.sourceVersion, to: 5, in: statement)
1041
-            bindText(row.sourceOperatingSystemVersion, to: 6, in: statement)
1042
-            bindText(row.sourceOperatingSystemVersion, to: 7, in: statement)
1057
+            bindText(productType, to: 2, in: statement)
1058
+            bindText(productType, to: 3, in: statement)
1059
+            bindText(version, to: 4, in: statement)
1060
+            bindText(version, to: 5, in: statement)
1061
+            bindText(operatingSystemVersion, to: 6, in: statement)
1062
+            bindText(operatingSystemVersion, to: 7, in: statement)
1043 1063
         }
1044 1064
     }
1045 1065
 
@@ -1831,8 +1851,13 @@ private struct ArchiveSampleRow {
1831 1851
         }
1832 1852
     }
1833 1853
 
1834
-    nonisolated private static func operatingSystemVersionString(_ version: OperatingSystemVersion) -> String {
1835
-        "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
1854
+    nonisolated private static func operatingSystemVersionString(_ version: OperatingSystemVersion) -> String? {
1855
+        guard (0...100).contains(version.majorVersion),
1856
+              (0...1_000).contains(version.minorVersion),
1857
+              (0...1_000).contains(version.patchVersion) else {
1858
+            return nil
1859
+        }
1860
+        return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
1836 1861
     }
1837 1862
 }
1838 1863
 
+165 -0
HealthProbeTests/SQLiteHealthArchiveStoreTests.swift
@@ -0,0 +1,165 @@
1
+import HealthKit
2
+import SQLite3
3
+import XCTest
4
+@testable import HealthProbe
5
+
6
+final class SQLiteHealthArchiveStoreTests: XCTestCase {
7
+    private var temporaryDirectory: URL!
8
+
9
+    override func setUpWithError() throws {
10
+        temporaryDirectory = FileManager.default.temporaryDirectory
11
+            .appending(path: "HealthProbeTests-\(UUID().uuidString)", directoryHint: .isDirectory)
12
+        try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true)
13
+    }
14
+
15
+    override func tearDownWithError() throws {
16
+        if let temporaryDirectory {
17
+            try? FileManager.default.removeItem(at: temporaryDirectory)
18
+        }
19
+        temporaryDirectory = nil
20
+    }
21
+
22
+    func testFreshArchiveInitializesSchemaAndPassesIntegrity() async throws {
23
+        let store = SQLiteHealthArchiveStore(databaseURL: databaseURL())
24
+
25
+        let report = try await store.checkIntegrity()
26
+        let records = try await store.records(for: HealthArchiveRecordRequest(
27
+            sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue
28
+        ))
29
+
30
+        XCTAssertTrue(report.passed)
31
+        XCTAssertEqual(report.schemaVersion, 2)
32
+        XCTAssertEqual(report.sqliteIntegrityStatus, "ok")
33
+        XCTAssertEqual(report.foreignKeyIssueCount, 0)
34
+        XCTAssertTrue(report.missingTableNames.isEmpty)
35
+        XCTAssertTrue(report.requiredTableNames.contains("sample_visibility_ranges"))
36
+        XCTAssertTrue(report.requiredTableNames.contains("daily_type_aggregates"))
37
+        XCTAssertTrue(records.isEmpty)
38
+    }
39
+
40
+    func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
41
+        let url = databaseURL()
42
+        try createPrototypeDatabase(at: url)
43
+        let store = SQLiteHealthArchiveStore(databaseURL: url)
44
+
45
+        let report = try await store.checkIntegrity()
46
+
47
+        XCTAssertTrue(report.passed)
48
+        XCTAssertEqual(report.schemaVersion, 2)
49
+        XCTAssertTrue(report.missingTableNames.isEmpty)
50
+    }
51
+
52
+    func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws {
53
+        let url = databaseURL()
54
+        let store = SQLiteHealthArchiveStore(databaseURL: url)
55
+        let sample = makeStepCountSample()
56
+
57
+        let firstWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
58
+        let secondWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_060))
59
+        let records = try await store.records(for: HealthArchiveRecordRequest(
60
+            sampleTypeIdentifier: sample.sampleType.identifier
61
+        ))
62
+        let report = try await store.checkIntegrity()
63
+        let versionDebugRows = try sampleVersionDebugRows(at: url)
64
+
65
+        XCTAssertEqual(firstWrite.insertedCount, 1)
66
+        XCTAssertEqual(firstWrite.updatedCount, 0)
67
+        XCTAssertEqual(firstWrite.unchangedCount, 0)
68
+        XCTAssertEqual(try countRows(in: "samples", at: url), 1)
69
+        XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows)
70
+        XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1)
71
+        XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1)
72
+        XCTAssertEqual(secondWrite.insertedCount, 0)
73
+        XCTAssertEqual(secondWrite.updatedCount, 0)
74
+        XCTAssertEqual(secondWrite.unchangedCount, 1)
75
+        XCTAssertEqual(records.count, 1)
76
+        XCTAssertEqual(records.first?.displayValue, "42.0 count")
77
+        XCTAssertTrue(report.passed)
78
+    }
79
+
80
+    private func databaseURL() -> URL {
81
+        temporaryDirectory.appending(path: "Archive.sqlite")
82
+    }
83
+
84
+    private func createPrototypeDatabase(at url: URL) throws {
85
+        var db: OpaquePointer?
86
+        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
87
+            sqlite3_close(db)
88
+            XCTFail("Could not create prototype database")
89
+            return
90
+        }
91
+        defer { sqlite3_close(db) }
92
+        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
93
+        XCTAssertEqual(status, SQLITE_OK)
94
+    }
95
+
96
+    private func makeStepCountSample() -> HKQuantitySample {
97
+        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
98
+        let quantity = HKQuantity(unit: .count(), doubleValue: 42)
99
+        let start = Date(timeIntervalSince1970: 1_000)
100
+        let end = Date(timeIntervalSince1970: 1_300)
101
+        return HKQuantitySample(type: quantityType, quantity: quantity, start: start, end: end)
102
+    }
103
+
104
+    private func countRows(in tableName: String, at url: URL) throws -> Int {
105
+        var db: OpaquePointer?
106
+        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
107
+            sqlite3_close(db)
108
+            XCTFail("Could not open test database")
109
+            return 0
110
+        }
111
+        defer { sqlite3_close(db) }
112
+
113
+        var statement: OpaquePointer?
114
+        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
115
+            sqlite3_finalize(statement)
116
+            XCTFail("Could not prepare count query")
117
+            return 0
118
+        }
119
+        defer { sqlite3_finalize(statement) }
120
+
121
+        guard sqlite3_step(statement) == SQLITE_ROW else {
122
+            return 0
123
+        }
124
+        return Int(sqlite3_column_int(statement, 0))
125
+    }
126
+
127
+    private func sampleVersionDebugRows(at url: URL) throws -> String {
128
+        var db: OpaquePointer?
129
+        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
130
+            sqlite3_close(db)
131
+            return "could not open database"
132
+        }
133
+        defer { sqlite3_close(db) }
134
+
135
+        let sql = """
136
+        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
137
+               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
138
+               sr.operating_system_version, v.hk_device_id, v.metadata_id
139
+        FROM sample_versions v
140
+        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
141
+        LEFT JOIN sources src ON src.id = sr.source_id
142
+        ORDER BY v.id
143
+        """
144
+        var statement: OpaquePointer?
145
+        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
146
+            sqlite3_finalize(statement)
147
+            return "could not prepare version debug query"
148
+        }
149
+        defer { sqlite3_finalize(statement) }
150
+
151
+        var rows: [String] = []
152
+        while sqlite3_step(statement) == SQLITE_ROW {
153
+            rows.append((0..<13).map { index in
154
+                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
155
+                    return "null"
156
+                }
157
+                if let text = sqlite3_column_text(statement, Int32(index)) {
158
+                    return String(cString: text)
159
+                }
160
+                return "\(sqlite3_column_double(statement, Int32(index)))"
161
+            }.joined(separator: "|"))
162
+        }
163
+        return rows.joined(separator: "\n")
164
+    }
165
+}