@@ -579,6 +579,7 @@ rows exist". |
||
| 579 | 579 |
| 2026-06-03 | pending | Harden quantity unit conversion for full-profile imports. | The first 127-metric run crashed while archiving `HKQuantityTypeIdentifierDietaryWater`: the previous fallback converted unknown quantities with `.count()`, and HealthKit raised `NSInvalidArgumentException` for incompatible `mL` to `count`. The archive now maps known extended units, stores quantity rows with nil numeric/unit when a future type is unmapped, and removes the unsafe display fallback. Next run should pass Water and reveal any remaining type-specific unit gaps. | |
| 580 | 580 |
| 2026-06-03 | pending | Preserve completed import diagnostics and render large diagnostic reports lazily. | A copied full-profile reimport report completed successfully: `Types: 127/127 processed`, `Records: 2,646,527`, `WallClock: 40.3s`, `SummedProcessingElapsed: 21.7s`, `SummedFinalizeElapsed: 15.1s`, `SummedFetchElapsed: 1.7s`, `SummedInsertElapsed: 0.1s`, and `0` degraded metrics. The Diagnostics sheet copied the report text but displayed a blank body for the huge report; completed/partial/review reports are now persisted under Application Support and displayed in chunked lazy text blocks. | |
| 581 | 581 |
| 2026-06-03 | pending | Fast-path unchanged verification after an empty HealthKit delta page. | The successful full-profile reimport spent `15.1s` in finalize despite `0.1s` insert time; Heart Rate alone spent `9.0s` finalizing. The empty-delta path now calls an explicit unchanged-verification store method that copies the previous type summary and daily aggregates without first scanning sample events to prove zero changes. Expected signal: repeated no-delta/full-profile `SummedFinalizeElapsed` and Heart Rate finalize time drop sharply. | |
| 582 |
+| 2026-06-03 | pending | Persist HealthKit capture anchors in SQLite. | A later no-delta attempt still reimported full high-volume types (`Records: 2,646,590`, `WallClock: 45.8s`, Heart Rate `922,521`, Active Energy `346,473`) because incremental anchors were still read from legacy SwiftData `TypeCount`, while the refactored app now chains observations from SQLite. SQLite now stores per-type capture state (`anchor_data`, count, content hash, yearly counts, range) and the import path uses it when legacy `TypeCount` state is missing. Expected signal: the first run after this change may seed anchors; the following no-delta run should report empty HealthKit delta pages instead of full `record_import` counts for large stable types. | |
|
| 582 | 583 |
|
| 583 | 584 |
## Current Diagnosis |
| 584 | 585 |
|
@@ -621,6 +622,10 @@ The likely bottleneck is per-row SQLite work: |
||
| 621 | 622 |
if it counts nonexistent events for high-volume types. The capture layer knows |
| 622 | 623 |
the first anchored delta page is empty, so unchanged verification should skip |
| 623 | 624 |
event-count scans and copy previous materialized summaries directly. |
| 625 |
+- Incremental anchors must be persisted in SQLite, not only in legacy SwiftData |
|
| 626 |
+ `TypeCount.distributionBinsData`. Otherwise refactored SQLite-driven snapshots |
|
| 627 |
+ lose their HealthKit query anchor across launches/refactors and every |
|
| 628 |
+ high-volume metric becomes a full scan again. |
|
| 624 | 629 |
|
| 625 | 630 |
## Open Issues / Observations |
| 626 | 631 |
|
@@ -653,7 +658,11 @@ Prioritize experiments in this order: |
||
| 653 | 658 |
diagnostic report does not freeze the app, and Dashboard/Snapshots show the |
| 654 | 659 |
latest observation from SQLite. Also verify Snapshot detail and Data Types |
| 655 | 660 |
show per-type summaries without a manual cache rebuild. |
| 656 |
-3. Run a repeated no-delta benchmark after copying unchanged metric summaries and daily aggregates. Compare `SummedFinalizeElapsed`, `Heart Rate finalizeElapsed`, `Active Energy finalizeElapsed`, and wall clock. |
|
| 661 |
+3. Run two repeated no-delta benchmarks after SQLite capture-state persistence: |
|
| 662 |
+ first to seed missing SQLite anchors if needed, second to confirm `record_import` |
|
| 663 |
+ for large stable types uses empty delta pages. Compare `WallClockDuration`, |
|
| 664 |
+ `SummedProcessingElapsed`, `SummedFinalizeElapsed`, Heart Rate count/timing, |
|
| 665 |
+ and Active Energy count/timing. |
|
| 657 | 666 |
4. Add or inspect timing around per-record processing for changed high-volume metrics, especially Heart Rate, to separate sample DTO/fingerprint work from SQLite idempotency checks. |
| 658 | 667 |
5. Run a non-chain-start/full-scan benchmark after skipping unchanged `verified` events and fast-pathing already-open visibility ranges. Compare `SummedInsertElapsed`, `Heart Rate insertElapsed`, `Steps insertElapsed`, and `Walking + Running Distance insertElapsed`. |
| 659 | 668 |
6. Reduce any remaining per-sample SQLite writes for unchanged existing samples during non-chain-start full scans. |
@@ -907,7 +907,11 @@ final class HealthKitService {
|
||
| 907 | 907 |
|
| 908 | 908 |
let earliest = earliestResult.value ?? nil |
| 909 | 909 |
let latest = latestResult.value ?? nil |
| 910 |
- let previousDistribution = PreviousDistributionState(typeCount: previousTypeCount) |
|
| 910 |
+ let previousArchiveState = try? await archiveStore.typeCaptureState(sampleTypeIdentifier: monitoredType.id) |
|
| 911 |
+ let previousDistribution = PreviousDistributionState( |
|
| 912 |
+ typeCount: previousTypeCount, |
|
| 913 |
+ archiveState: previousArchiveState |
|
| 914 |
+ ) |
|
| 911 | 915 |
let distributionResult = await measureAPICall( |
| 912 | 916 |
queryType: "record_import", |
| 913 | 917 |
timeoutSeconds: nil |
@@ -922,7 +926,12 @@ final class HealthKitService {
|
||
| 922 | 926 |
progress: progress |
| 923 | 927 |
) |
| 924 | 928 |
} resultDescription: { distribution in
|
| 925 |
- "\(distribution.totalCount) samples in \(distribution.bins.count) anchored segment(s)" |
|
| 929 |
+ if distribution.recordArchiveData == nil, |
|
| 930 |
+ distribution.records.isEmpty, |
|
| 931 |
+ distribution.totalCount > 0 {
|
|
| 932 |
+ return "\(distribution.totalCount) unchanged samples via empty HealthKit delta" |
|
| 933 |
+ } |
|
| 934 |
+ return "\(distribution.totalCount) samples in \(distribution.bins.count) anchored segment(s)" |
|
| 926 | 935 |
} |
| 927 | 936 |
apiCalls.insert(distributionResult.apiCall, at: 0) |
| 928 | 937 |
|
@@ -983,7 +992,14 @@ final class HealthKitService {
|
||
| 983 | 992 |
var timingBreakdown = distribution.timingBreakdown |
| 984 | 993 |
timingBreakdown.fetchElapsedSeconds += dateFetchElapsedSeconds |
| 985 | 994 |
let recordArchiveStartedAt = Date() |
| 986 |
- let recordArchiveData = distribution.recordArchiveData ?? HealthRecordArchive.encode(distribution.records) |
|
| 995 |
+ let recordArchiveData: Data? |
|
| 996 |
+ if let distributionArchiveData = distribution.recordArchiveData {
|
|
| 997 |
+ recordArchiveData = distributionArchiveData |
|
| 998 |
+ } else if distribution.records.isEmpty {
|
|
| 999 |
+ recordArchiveData = nil |
|
| 1000 |
+ } else {
|
|
| 1001 |
+ recordArchiveData = HealthRecordArchive.encode(distribution.records) |
|
| 1002 |
+ } |
|
| 987 | 1003 |
timingBreakdown.processingElapsedSeconds += Date().timeIntervalSince(recordArchiveStartedAt) |
| 988 | 1004 |
|
| 989 | 1005 |
var result = TypeCountFetchResult( |
@@ -1012,9 +1028,33 @@ final class HealthKitService {
|
||
| 1012 | 1028 |
recordArchiveData: recordArchiveData |
| 1013 | 1029 |
) |
| 1014 | 1030 |
result.timingBreakdown = timingBreakdown |
| 1031 |
+ await persistTypeCaptureState(from: result, observationID: archiveObservationID) |
|
| 1015 | 1032 |
return result |
| 1016 | 1033 |
} |
| 1017 | 1034 |
|
| 1035 |
+ private func persistTypeCaptureState(from result: TypeCountFetchResult, observationID: Int64) async {
|
|
| 1036 |
+ guard result.quality == .complete, |
|
| 1037 |
+ result.count >= 0, |
|
| 1038 |
+ let anchorData = result.distributionBins.last?.anchorData else {
|
|
| 1039 |
+ return |
|
| 1040 |
+ } |
|
| 1041 |
+ let yearlyCounts = Dictionary(uniqueKeysWithValues: result.yearlyCounts.map { ($0.year, $0.count) })
|
|
| 1042 |
+ let state = HealthArchiveTypeCaptureState( |
|
| 1043 |
+ sampleTypeIdentifier: result.typeIdentifier, |
|
| 1044 |
+ count: result.count, |
|
| 1045 |
+ contentHash: result.contentHash, |
|
| 1046 |
+ earliestDate: result.earliestDate, |
|
| 1047 |
+ latestDate: result.latestDate, |
|
| 1048 |
+ yearlyCounts: yearlyCounts, |
|
| 1049 |
+ anchorData: anchorData |
|
| 1050 |
+ ) |
|
| 1051 |
+ do {
|
|
| 1052 |
+ try await archiveStore.upsertTypeCaptureState(state, observationID: observationID) |
|
| 1053 |
+ } catch {
|
|
| 1054 |
+ print("[HealthProbeImport] capture_state_persist_failed type=\(result.typeIdentifier) error=\(error)")
|
|
| 1055 |
+ } |
|
| 1056 |
+ } |
|
| 1057 |
+ |
|
| 1018 | 1058 |
// MARK: - HealthKit queries |
| 1019 | 1059 |
|
| 1020 | 1060 |
private func fetchDistribution( |
@@ -1066,6 +1106,28 @@ final class HealthKitService {
|
||
| 1066 | 1106 |
) |
| 1067 | 1107 |
} |
| 1068 | 1108 |
captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(firstPageFetchStartedAt) |
| 1109 |
+ anchor = page.anchor |
|
| 1110 |
+ |
|
| 1111 |
+ if !page.samples.isEmpty || !page.deletedObjects.isEmpty, |
|
| 1112 |
+ !previousDistribution.canApplyDeltaChanges {
|
|
| 1113 |
+ progress?.updateBlockProgress( |
|
| 1114 |
+ typeIdentifier, |
|
| 1115 |
+ detail: "Delta requires full archive refresh", |
|
| 1116 |
+ recordCount: previousDistribution.count, |
|
| 1117 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted), |
|
| 1118 |
+ samplesPerSecond: 0 |
|
| 1119 |
+ ) |
|
| 1120 |
+ return try await fetchInitialDistributionStreaming( |
|
| 1121 |
+ for: sampleType, |
|
| 1122 |
+ typeIdentifier: typeIdentifier, |
|
| 1123 |
+ earliestDate: earliestDate, |
|
| 1124 |
+ latestDate: latestDate, |
|
| 1125 |
+ progressStarted: progressStarted, |
|
| 1126 |
+ archiveObservationID: archiveObservationID, |
|
| 1127 |
+ progress: progress |
|
| 1128 |
+ ) |
|
| 1129 |
+ } |
|
| 1130 |
+ |
|
| 1069 | 1131 |
let archiveResult = try await archivePage( |
| 1070 | 1132 |
page, |
| 1071 | 1133 |
sampleType: sampleType, |
@@ -1080,7 +1142,6 @@ final class HealthKitService {
|
||
| 1080 | 1142 |
) |
| 1081 | 1143 |
persistenceState = archiveResult.persistenceState |
| 1082 | 1144 |
captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds |
| 1083 |
- anchor = page.anchor |
|
| 1084 | 1145 |
|
| 1085 | 1146 |
if page.samples.isEmpty, page.deletedObjects.isEmpty, |
| 1086 | 1147 |
let unchanged = previousDistribution.unchangedDistribution( |
@@ -2524,22 +2585,44 @@ private struct PreviousDistributionState: Sendable {
|
||
| 2524 | 2585 |
return bins[0].anchor |
| 2525 | 2586 |
} |
| 2526 | 2587 |
|
| 2527 |
- init(typeCount: TypeCount?) {
|
|
| 2528 |
- self.typeIdentifier = typeCount?.typeIdentifier ?? "" |
|
| 2529 |
- self.count = typeCount?.count ?? 0 |
|
| 2530 |
- self.contentHash = typeCount?.contentHash ?? "" |
|
| 2531 |
- self.earliestRecordDate = typeCount?.earliestDate |
|
| 2532 |
- self.latestRecordDate = typeCount?.latestDate |
|
| 2588 |
+ var canApplyDeltaChanges: Bool {
|
|
| 2589 |
+ count == 0 || recordArchiveData != nil |
|
| 2590 |
+ } |
|
| 2591 |
+ |
|
| 2592 |
+ init(typeCount: TypeCount?, archiveState: HealthArchiveTypeCaptureState?) {
|
|
| 2593 |
+ let typeCountBins = typeCount?.distributionBins ?? [] |
|
| 2594 |
+ self.typeIdentifier = typeCount?.typeIdentifier ?? archiveState?.sampleTypeIdentifier ?? "" |
|
| 2595 |
+ self.count = typeCount?.count ?? archiveState?.count ?? 0 |
|
| 2596 |
+ self.contentHash = typeCount?.contentHash ?? archiveState?.contentHash ?? "" |
|
| 2597 |
+ self.earliestRecordDate = typeCount?.earliestDate ?? archiveState?.earliestDate |
|
| 2598 |
+ self.latestRecordDate = typeCount?.latestDate ?? archiveState?.latestDate |
|
| 2533 | 2599 |
self.recordArchiveData = typeCount?.recordArchiveData |
| 2534 |
- self.yearlyCounts = Dictionary( |
|
| 2600 |
+ let typeCountYearlyCounts = Dictionary( |
|
| 2535 | 2601 |
uniqueKeysWithValues: (typeCount?.yearlyCounts ?? []).map { ($0.year, $0.count) }
|
| 2536 | 2602 |
) |
| 2537 |
- self.bins = (typeCount?.distributionBins ?? []).map {
|
|
| 2538 |
- Bin( |
|
| 2539 |
- bucketStart: $0.bucketStart, |
|
| 2540 |
- bucketEnd: $0.bucketEnd, |
|
| 2541 |
- anchorData: $0.anchorData |
|
| 2542 |
- ) |
|
| 2603 |
+ self.yearlyCounts = typeCountYearlyCounts.isEmpty |
|
| 2604 |
+ ? archiveState?.yearlyCounts ?? [:] |
|
| 2605 |
+ : typeCountYearlyCounts |
|
| 2606 |
+ if typeCountBins.count == 1, typeCountBins[0].anchorData != nil {
|
|
| 2607 |
+ self.bins = typeCountBins.map {
|
|
| 2608 |
+ Bin( |
|
| 2609 |
+ bucketStart: $0.bucketStart, |
|
| 2610 |
+ bucketEnd: $0.bucketEnd, |
|
| 2611 |
+ anchorData: $0.anchorData |
|
| 2612 |
+ ) |
|
| 2613 |
+ } |
|
| 2614 |
+ } else if let archiveState, let anchorData = archiveState.anchorData {
|
|
| 2615 |
+ let bucketStart = archiveState.earliestDate ?? Date() |
|
| 2616 |
+ let rawBucketEnd = archiveState.latestDate ?? bucketStart |
|
| 2617 |
+ self.bins = [ |
|
| 2618 |
+ Bin( |
|
| 2619 |
+ bucketStart: bucketStart, |
|
| 2620 |
+ bucketEnd: rawBucketEnd > bucketStart ? rawBucketEnd : bucketStart.addingTimeInterval(1), |
|
| 2621 |
+ anchorData: anchorData |
|
| 2622 |
+ ) |
|
| 2623 |
+ ] |
|
| 2624 |
+ } else {
|
|
| 2625 |
+ self.bins = [] |
|
| 2543 | 2626 |
} |
| 2544 | 2627 |
} |
| 2545 | 2628 |
|
@@ -2550,7 +2633,6 @@ private struct PreviousDistributionState: Sendable {
|
||
| 2550 | 2633 |
latestDate: Date? |
| 2551 | 2634 |
) -> SampleDistribution? {
|
| 2552 | 2635 |
guard count >= 0, |
| 2553 |
- count == 0 || recordArchiveData != nil, |
|
| 2554 | 2636 |
count == 0 || !contentHash.isEmpty else {
|
| 2555 | 2637 |
return nil |
| 2556 | 2638 |
} |
@@ -5,6 +5,8 @@ import HealthKit |
||
| 5 | 5 |
protocol HealthArchiveStore {
|
| 6 | 6 |
func beginObservation(observedAt: Date, triggerReason: String, selectedTypeSetHash: String?) async throws -> Int64 |
| 7 | 7 |
func finishObservation(observationID: Int64, status: String, endedAt: Date) async throws |
| 8 |
+ func typeCaptureState(sampleTypeIdentifier: String) async throws -> HealthArchiveTypeCaptureState? |
|
| 9 |
+ func upsertTypeCaptureState(_ state: HealthArchiveTypeCaptureState, observationID: Int64) async throws |
|
| 8 | 10 |
func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary |
| 9 | 11 |
func upsertSamples(_ samples: [HKSample], observedAt: Date, observationID: Int64) async throws -> HealthArchiveWriteSummary |
| 10 | 12 |
func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws |
@@ -30,6 +32,37 @@ struct HealthArchiveWriteSummary: Equatable, Sendable {
|
||
| 30 | 32 |
let unchangedCount: Int |
| 31 | 33 |
} |
| 32 | 34 |
|
| 35 |
+struct HealthArchiveTypeCaptureState: Equatable, Sendable {
|
|
| 36 |
+ let sampleTypeIdentifier: String |
|
| 37 |
+ let observationID: Int64? |
|
| 38 |
+ let count: Int |
|
| 39 |
+ let contentHash: String |
|
| 40 |
+ let earliestDate: Date? |
|
| 41 |
+ let latestDate: Date? |
|
| 42 |
+ let yearlyCounts: [Int: Int] |
|
| 43 |
+ let anchorData: Data? |
|
| 44 |
+ |
|
| 45 |
+ init( |
|
| 46 |
+ sampleTypeIdentifier: String, |
|
| 47 |
+ observationID: Int64? = nil, |
|
| 48 |
+ count: Int, |
|
| 49 |
+ contentHash: String, |
|
| 50 |
+ earliestDate: Date?, |
|
| 51 |
+ latestDate: Date?, |
|
| 52 |
+ yearlyCounts: [Int: Int], |
|
| 53 |
+ anchorData: Data? |
|
| 54 |
+ ) {
|
|
| 55 |
+ self.sampleTypeIdentifier = sampleTypeIdentifier |
|
| 56 |
+ self.observationID = observationID |
|
| 57 |
+ self.count = count |
|
| 58 |
+ self.contentHash = contentHash |
|
| 59 |
+ self.earliestDate = earliestDate |
|
| 60 |
+ self.latestDate = latestDate |
|
| 61 |
+ self.yearlyCounts = yearlyCounts |
|
| 62 |
+ self.anchorData = anchorData |
|
| 63 |
+ } |
|
| 64 |
+} |
|
| 65 |
+ |
|
| 33 | 66 |
struct HealthArchiveIntegrityReport: Equatable, Sendable {
|
| 34 | 67 |
let schemaVersion: Int? |
| 35 | 68 |
let sqliteIntegrityStatus: String |
@@ -33,6 +33,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 33 | 33 |
"sample_visibility_ranges", |
| 34 | 34 |
"sample_relationships", |
| 35 | 35 |
"observation_type_summaries", |
| 36 |
+ "type_capture_states", |
|
| 36 | 37 |
"daily_type_aggregates", |
| 37 | 38 |
"export_manifests", |
| 38 | 39 |
"export_items" |
@@ -40,6 +41,8 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 40 | 41 |
|
| 41 | 42 |
private let databaseURL: URL |
| 42 | 43 |
private var didPrepareSchema = false |
| 44 |
+ private nonisolated static let captureStateEncoder = PropertyListEncoder() |
|
| 45 |
+ private nonisolated static let captureStateDecoder = PropertyListDecoder() |
|
| 43 | 46 |
|
| 44 | 47 |
private static let monitoredTypeMetadataByIdentifier: [String: (displayName: String, category: String)] = {
|
| 45 | 48 |
Dictionary(uniqueKeysWithValues: HealthKitService.allTypes.map {
|
@@ -107,6 +110,28 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 107 | 110 |
return try typeSummaries(observationID: observationID, limit: limit, db: db) |
| 108 | 111 |
} |
| 109 | 112 |
|
| 113 |
+ func typeCaptureState(sampleTypeIdentifier: String) async throws -> HealthArchiveTypeCaptureState? {
|
|
| 114 |
+ let db = try openDatabase() |
|
| 115 |
+ defer { sqlite3_close(db) }
|
|
| 116 |
+ try prepareSchemaIfNeeded(db) |
|
| 117 |
+ |
|
| 118 |
+ return try typeCaptureState(sampleTypeIdentifier: sampleTypeIdentifier, db: db) |
|
| 119 |
+ } |
|
| 120 |
+ |
|
| 121 |
+ func upsertTypeCaptureState(_ state: HealthArchiveTypeCaptureState, observationID: Int64) async throws {
|
|
| 122 |
+ let db = try openDatabase() |
|
| 123 |
+ defer { sqlite3_close(db) }
|
|
| 124 |
+ try prepareSchemaIfNeeded(db) |
|
| 125 |
+ try execute("BEGIN IMMEDIATE TRANSACTION", db: db)
|
|
| 126 |
+ do {
|
|
| 127 |
+ try upsertTypeCaptureState(state, observationID: observationID, db: db) |
|
| 128 |
+ try execute("COMMIT", db: db)
|
|
| 129 |
+ } catch {
|
|
| 130 |
+ try? execute("ROLLBACK", db: db)
|
|
| 131 |
+ throw error |
|
| 132 |
+ } |
|
| 133 |
+ } |
|
| 134 |
+ |
|
| 110 | 135 |
func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary {
|
| 111 | 136 |
guard !samples.isEmpty else {
|
| 112 | 137 |
return HealthArchiveWriteSummary(insertedCount: 0, updatedCount: 0, unchangedCount: 0) |
@@ -1786,6 +1811,20 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1786 | 1811 |
) |
| 1787 | 1812 |
""", db: db) |
| 1788 | 1813 |
try execute("""
|
| 1814 |
+ CREATE TABLE IF NOT EXISTS type_capture_states ( |
|
| 1815 |
+ sample_type_id INTEGER PRIMARY KEY REFERENCES sample_types(id), |
|
| 1816 |
+ observation_id INTEGER REFERENCES observations(id), |
|
| 1817 |
+ anchor_data BLOB, |
|
| 1818 |
+ record_count INTEGER NOT NULL, |
|
| 1819 |
+ content_hash TEXT NOT NULL, |
|
| 1820 |
+ earliest_start_date REAL, |
|
| 1821 |
+ latest_end_date REAL, |
|
| 1822 |
+ yearly_counts_data BLOB, |
|
| 1823 |
+ updated_at REAL NOT NULL |
|
| 1824 |
+ ) |
|
| 1825 |
+ """, db: db) |
|
| 1826 |
+ try execute("CREATE INDEX IF NOT EXISTS idx_type_capture_states_observation ON type_capture_states(observation_id)", db: db)
|
|
| 1827 |
+ try execute("""
|
|
| 1789 | 1828 |
CREATE TABLE IF NOT EXISTS daily_type_aggregates ( |
| 1790 | 1829 |
observation_id INTEGER NOT NULL REFERENCES observations(id), |
| 1791 | 1830 |
sample_type_id INTEGER NOT NULL REFERENCES sample_types(id), |
@@ -2485,6 +2524,91 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2485 | 2524 |
return rows |
| 2486 | 2525 |
} |
| 2487 | 2526 |
|
| 2527 |
+ private func typeCaptureState( |
|
| 2528 |
+ sampleTypeIdentifier: String, |
|
| 2529 |
+ db: OpaquePointer? |
|
| 2530 |
+ ) throws -> HealthArchiveTypeCaptureState? {
|
|
| 2531 |
+ let sql = """ |
|
| 2532 |
+ SELECT |
|
| 2533 |
+ t.type_identifier, |
|
| 2534 |
+ s.observation_id, |
|
| 2535 |
+ s.record_count, |
|
| 2536 |
+ s.content_hash, |
|
| 2537 |
+ s.earliest_start_date, |
|
| 2538 |
+ s.latest_end_date, |
|
| 2539 |
+ s.yearly_counts_data, |
|
| 2540 |
+ s.anchor_data |
|
| 2541 |
+ FROM type_capture_states s |
|
| 2542 |
+ JOIN sample_types t ON t.id = s.sample_type_id |
|
| 2543 |
+ WHERE t.type_identifier = ? |
|
| 2544 |
+ LIMIT 1 |
|
| 2545 |
+ """ |
|
| 2546 |
+ |
|
| 2547 |
+ return try withStatement(sql, db: db) { statement in
|
|
| 2548 |
+ bindText(sampleTypeIdentifier, to: 1, in: statement) |
|
| 2549 |
+ guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
|
|
| 2550 |
+ let yearlyCountsData = columnData(statement, 6) |
|
| 2551 |
+ let yearlyCounts = yearlyCountsData.flatMap {
|
|
| 2552 |
+ try? Self.captureStateDecoder.decode([Int: Int].self, from: $0) |
|
| 2553 |
+ } ?? [:] |
|
| 2554 |
+ return HealthArchiveTypeCaptureState( |
|
| 2555 |
+ sampleTypeIdentifier: columnText(statement, 0) ?? sampleTypeIdentifier, |
|
| 2556 |
+ observationID: columnInt64(statement, 1), |
|
| 2557 |
+ count: columnInt(statement, 2) ?? 0, |
|
| 2558 |
+ contentHash: columnText(statement, 3) ?? "", |
|
| 2559 |
+ earliestDate: columnUnixDate(statement, 4), |
|
| 2560 |
+ latestDate: columnUnixDate(statement, 5), |
|
| 2561 |
+ yearlyCounts: yearlyCounts, |
|
| 2562 |
+ anchorData: columnData(statement, 7) |
|
| 2563 |
+ ) |
|
| 2564 |
+ } |
|
| 2565 |
+ } |
|
| 2566 |
+ |
|
| 2567 |
+ private func upsertTypeCaptureState( |
|
| 2568 |
+ _ state: HealthArchiveTypeCaptureState, |
|
| 2569 |
+ observationID: Int64, |
|
| 2570 |
+ db: OpaquePointer? |
|
| 2571 |
+ ) throws {
|
|
| 2572 |
+ let sampleTypeID = try upsertSampleType(typeIdentifier: state.sampleTypeIdentifier, db: db) |
|
| 2573 |
+ let yearlyCountsData = try? Self.captureStateEncoder.encode(state.yearlyCounts) |
|
| 2574 |
+ let sql = """ |
|
| 2575 |
+ INSERT INTO type_capture_states ( |
|
| 2576 |
+ sample_type_id, |
|
| 2577 |
+ observation_id, |
|
| 2578 |
+ anchor_data, |
|
| 2579 |
+ record_count, |
|
| 2580 |
+ content_hash, |
|
| 2581 |
+ earliest_start_date, |
|
| 2582 |
+ latest_end_date, |
|
| 2583 |
+ yearly_counts_data, |
|
| 2584 |
+ updated_at |
|
| 2585 |
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
| 2586 |
+ ON CONFLICT(sample_type_id) DO UPDATE SET |
|
| 2587 |
+ observation_id = excluded.observation_id, |
|
| 2588 |
+ anchor_data = excluded.anchor_data, |
|
| 2589 |
+ record_count = excluded.record_count, |
|
| 2590 |
+ content_hash = excluded.content_hash, |
|
| 2591 |
+ earliest_start_date = excluded.earliest_start_date, |
|
| 2592 |
+ latest_end_date = excluded.latest_end_date, |
|
| 2593 |
+ yearly_counts_data = excluded.yearly_counts_data, |
|
| 2594 |
+ updated_at = excluded.updated_at |
|
| 2595 |
+ """ |
|
| 2596 |
+ try withStatement(sql, db: db) { statement in
|
|
| 2597 |
+ bindInt64(sampleTypeID, to: 1, in: statement) |
|
| 2598 |
+ bindInt64(observationID, to: 2, in: statement) |
|
| 2599 |
+ bindData(state.anchorData, to: 3, in: statement) |
|
| 2600 |
+ bindInt(state.count, to: 4, in: statement) |
|
| 2601 |
+ bindText(state.contentHash, to: 5, in: statement) |
|
| 2602 |
+ bindDouble(state.earliestDate?.timeIntervalSince1970, to: 6, in: statement) |
|
| 2603 |
+ bindDouble(state.latestDate?.timeIntervalSince1970, to: 7, in: statement) |
|
| 2604 |
+ bindData(yearlyCountsData, to: 8, in: statement) |
|
| 2605 |
+ sqlite3_bind_double(statement, 9, Date().timeIntervalSince1970) |
|
| 2606 |
+ guard sqlite3_step(statement) == SQLITE_DONE else {
|
|
| 2607 |
+ throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
|
| 2608 |
+ } |
|
| 2609 |
+ } |
|
| 2610 |
+ } |
|
| 2611 |
+ |
|
| 2488 | 2612 |
private func updateObservationStatus( |
| 2489 | 2613 |
observationID: Int64, |
| 2490 | 2614 |
status: String, |
@@ -4141,6 +4265,17 @@ nonisolated private func bindText(_ value: String?, to index: Int32, in statemen |
||
| 4141 | 4265 |
sqlite3_bind_text(statement, index, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) |
| 4142 | 4266 |
} |
| 4143 | 4267 |
|
| 4268 |
+nonisolated private func bindData(_ value: Data?, to index: Int32, in statement: OpaquePointer?) {
|
|
| 4269 |
+ guard let value else {
|
|
| 4270 |
+ sqlite3_bind_null(statement, index) |
|
| 4271 |
+ return |
|
| 4272 |
+ } |
|
| 4273 |
+ value.withUnsafeBytes { rawBuffer in
|
|
| 4274 |
+ let pointer = rawBuffer.baseAddress |
|
| 4275 |
+ sqlite3_bind_blob(statement, index, pointer, Int32(value.count), unsafeBitCast(-1, to: sqlite3_destructor_type.self)) |
|
| 4276 |
+ } |
|
| 4277 |
+} |
|
| 4278 |
+ |
|
| 4144 | 4279 |
nonisolated private func bindDouble(_ value: Double?, to index: Int32, in statement: OpaquePointer?) {
|
| 4145 | 4280 |
guard let value else {
|
| 4146 | 4281 |
sqlite3_bind_null(statement, index) |
@@ -4173,6 +4308,15 @@ nonisolated private func columnText(_ statement: OpaquePointer?, _ index: Int32) |
||
| 4173 | 4308 |
return String(cString: pointer) |
| 4174 | 4309 |
} |
| 4175 | 4310 |
|
| 4311 |
+nonisolated private func columnData(_ statement: OpaquePointer?, _ index: Int32) -> Data? {
|
|
| 4312 |
+ guard sqlite3_column_type(statement, index) != SQLITE_NULL, |
|
| 4313 |
+ let bytes = sqlite3_column_blob(statement, index) else {
|
|
| 4314 |
+ return nil |
|
| 4315 |
+ } |
|
| 4316 |
+ let count = Int(sqlite3_column_bytes(statement, index)) |
|
| 4317 |
+ return Data(bytes: bytes, count: count) |
|
| 4318 |
+} |
|
| 4319 |
+ |
|
| 4176 | 4320 |
nonisolated private func columnDate(_ statement: OpaquePointer?, _ index: Int32) -> Date? {
|
| 4177 | 4321 |
guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil }
|
| 4178 | 4322 |
return Date(timeIntervalSinceReferenceDate: sqlite3_column_double(statement, index)) |
@@ -41,6 +41,42 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 41 | 41 |
XCTAssertTrue(records.isEmpty) |
| 42 | 42 |
} |
| 43 | 43 |
|
| 44 |
+ func testTypeCaptureStateRoundTripsAnchorAndSummary() async throws {
|
|
| 45 |
+ let url = databaseURL() |
|
| 46 |
+ let store = SQLiteHealthArchiveStore(databaseURL: url) |
|
| 47 |
+ let observationID = try await store.beginObservation( |
|
| 48 |
+ observedAt: Date(timeIntervalSince1970: 2_000), |
|
| 49 |
+ triggerReason: "manual", |
|
| 50 |
+ selectedTypeSetHash: "selected-types" |
|
| 51 |
+ ) |
|
| 52 |
+ let expected = HealthArchiveTypeCaptureState( |
|
| 53 |
+ sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue, |
|
| 54 |
+ count: 42, |
|
| 55 |
+ contentHash: "content-hash", |
|
| 56 |
+ earliestDate: Date(timeIntervalSince1970: 1_000), |
|
| 57 |
+ latestDate: Date(timeIntervalSince1970: 1_500), |
|
| 58 |
+ yearlyCounts: [2026: 42], |
|
| 59 |
+ anchorData: Data([0xde, 0xad, 0xbe, 0xef]) |
|
| 60 |
+ ) |
|
| 61 |
+ |
|
| 62 |
+ try await store.upsertTypeCaptureState(expected, observationID: observationID) |
|
| 63 |
+ let actual = try await store.typeCaptureState(sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue) |
|
| 64 |
+ let report = try await store.checkIntegrity() |
|
| 65 |
+ |
|
| 66 |
+ XCTAssertEqual(actual, HealthArchiveTypeCaptureState( |
|
| 67 |
+ sampleTypeIdentifier: expected.sampleTypeIdentifier, |
|
| 68 |
+ observationID: observationID, |
|
| 69 |
+ count: expected.count, |
|
| 70 |
+ contentHash: expected.contentHash, |
|
| 71 |
+ earliestDate: expected.earliestDate, |
|
| 72 |
+ latestDate: expected.latestDate, |
|
| 73 |
+ yearlyCounts: expected.yearlyCounts, |
|
| 74 |
+ anchorData: expected.anchorData |
|
| 75 |
+ )) |
|
| 76 |
+ XCTAssertTrue(report.passed) |
|
| 77 |
+ XCTAssertEqual(try countRows(in: "type_capture_states", at: url), 1) |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 44 | 80 |
func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
|
| 45 | 81 |
let url = databaseURL() |
| 46 | 82 |
try createPrototypeDatabase(at: url) |