Showing 5 changed files with 323 additions and 19 deletions
+10 -1
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -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.
+100 -18
HealthProbe/Services/HealthKitService.swift
@@ -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
         }
+33 -0
HealthProbe/Services/Protocols/HealthArchiveStore.swift
@@ -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
+144 -0
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -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))
+36 -0
HealthProbeTests/SQLiteHealthArchiveStoreTests.swift
@@ -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)