Showing 5 changed files with 847 additions and 80 deletions
+9 -8
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 bootstrap exists; legacy write table still active | Move write path from `archive_samples` to observations/samples/versions/events/ranges |
28
+| SQLite archive | Archive v2 schema and differential write path partially implemented; legacy read table still active | Add daily aggregates, SQL reads, integrity 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,15 @@ 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. Implement differential write path: observations, samples, payload versions, events/ranges, aggregates.
41
+1. Finish differential write path: daily aggregates and stricter retry/idempotency tests.
42 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.
43
+3. Move archive reads from `archive_samples` to SQL over visibility ranges and sample versions.
44
+4. Move large diffs/counts into SQL queries with indexes/temp tables/paged results.
45
+5. Add Core Data UI/report cache and rebuild pipeline.
46
+6. Replace SwiftData UI dependencies with Core Data/cache DTOs.
47
+7. Update UI language from anomaly/status to observation/diff/export.
48
+8. Add streaming exports with manifests.
49
+9. Validate on low-memory/legacy-class devices.
49 50
 
50 51
 ## Known Prototype Mismatches
51 52
 
+14 -14
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -117,24 +117,24 @@ Acceptance:
117 117
 **Purpose:** Write observations without storing full recurring snapshots.
118 118
 
119 119
 Checklist:
120
-- [ ] Create observation transaction wrapper.
121
-- [ ] Upsert sample types.
122
-- [ ] Upsert source/source revision/device/metadata rows.
123
-- [ ] Upsert sample identity.
124
-- [ ] Upsert sample payload version only when payload changes.
125
-- [ ] Insert appeared/verified/representationChanged events.
126
-- [ ] Record `HKDeletedObject` evidence by UUID hash.
127
-- [ ] Close visibility ranges for disappeared/deleted samples.
128
-- [ ] Maintain open visibility ranges for visible samples.
120
+- [x] Create observation transaction wrapper.
121
+- [x] Upsert sample types.
122
+- [x] Upsert source/source revision/device/metadata rows.
123
+- [x] Upsert sample identity.
124
+- [x] Upsert sample payload version only when payload changes.
125
+- [x] Insert appeared/verified/representationChanged events.
126
+- [x] Record `HKDeletedObject` evidence by UUID hash.
127
+- [x] Close visibility ranges for disappeared/deleted samples.
128
+- [x] Maintain open visibility ranges for visible samples.
129 129
 - [ ] Rebuild/update affected aggregates after capture.
130
-- [ ] Commit SQLite before Core Data/cache work.
130
+- [x] Commit SQLite before Core Data/cache work.
131 131
 - [ ] Make repeated capture page writes idempotent.
132 132
 
133 133
 Acceptance:
134
-- [ ] Initial import stores identities and versions once.
135
-- [ ] Re-running same page does not duplicate records.
136
-- [ ] Representation change creates a new version, not a new logical sample.
137
-- [ ] Disappearance closes visibility range.
134
+- [x] Initial import stores identities and versions once.
135
+- [x] Re-running same page does not duplicate sample identities or payload versions.
136
+- [x] Representation change creates a new version, not a new logical sample.
137
+- [x] Disappearance closes visibility range.
138 138
 - [ ] No full observation copy table is written.
139 139
 
140 140
 ## Milestone 5 - SQL Analysis Layer
+14 -8
HealthProbe/Services/HashService.swift
@@ -20,17 +20,23 @@ enum HashService {
20 20
         }
21 21
     }
22 22
 
23
-    private static let iso8601Formatter: ISO8601DateFormatter = {
23
+    nonisolated(unsafe) private static let iso8601Formatter: ISO8601DateFormatter = {
24 24
         let f = ISO8601DateFormatter()
25 25
         f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
26 26
         return f
27 27
     }()
28 28
 
29
+    nonisolated static func archiveContentHash(domain: String, parts: [String?]) -> String {
30
+        let input = ([domain] + parts.map { $0 ?? "null" }).joined(separator: "\u{1f}")
31
+        let digest = SHA256.hash(data: Data(input.utf8))
32
+        return digest.map { String(format: "%02x", $0) }.joined()
33
+    }
34
+
29 35
     // SHA256 of "typeIdentifier|totalCount|earliestDateISO|latestDateISO"
30 36
     // ⚠️ MVP limitation: hash covers only count + date range, not value distribution.
31 37
     // silentReplacement detection based on this hash is best-effort only —
32 38
     // it will miss replacements that preserve total count and date boundaries.
33
-    static func typeHash(
39
+    nonisolated static func typeHash(
34 40
         typeIdentifier: String,
35 41
         totalCount: Int,
36 42
         earliestDate: Date?,
@@ -43,7 +49,7 @@ enum HashService {
43 49
         return digest.map { String(format: "%02x", $0) }.joined()
44 50
     }
45 51
 
46
-    static func typeHash(typeIdentifier: String, recordFingerprints: [String]) -> String {
52
+    nonisolated static func typeHash(typeIdentifier: String, recordFingerprints: [String]) -> String {
47 53
         var hasher = SHA256()
48 54
         hasher.update(data: Data(typeIdentifier.utf8))
49 55
         for fingerprint in recordFingerprints.sorted() {
@@ -54,7 +60,7 @@ enum HashService {
54 60
         return digest.map { String(format: "%02x", $0) }.joined()
55 61
     }
56 62
 
57
-    static func typeHashInRecordedOrder(typeIdentifier: String, recordFingerprints: [String]) -> String {
63
+    nonisolated static func typeHashInRecordedOrder(typeIdentifier: String, recordFingerprints: [String]) -> String {
58 64
         var builder = TypeHashBuilder(typeIdentifier: typeIdentifier)
59 65
         for fingerprint in recordFingerprints {
60 66
             builder.append(recordFingerprint: fingerprint)
@@ -62,7 +68,7 @@ enum HashService {
62 68
         return builder.finalize()
63 69
     }
64 70
 
65
-    static func sampleFingerprint(
71
+    nonisolated static func sampleFingerprint(
66 72
         typeIdentifier: String,
67 73
         sampleUUID: String,
68 74
         startDate: Date,
@@ -78,12 +84,12 @@ enum HashService {
78 84
         return digest.map { String(format: "%02x", $0) }.joined()
79 85
     }
80 86
 
81
-    static func sampleUUIDHash(_ sampleUUID: String) -> String {
87
+    nonisolated static func sampleUUIDHash(_ sampleUUID: String) -> String {
82 88
         let digest = SHA256.hash(data: Data(sampleUUID.utf8))
83 89
         return digest.map { String(format: "%02x", $0) }.joined()
84 90
     }
85 91
 
86
-    static func archiveSemanticFingerprint(
92
+    nonisolated static func archiveSemanticFingerprint(
87 93
         typeIdentifier: String,
88 94
         startDate: Date,
89 95
         endDate: Date,
@@ -124,7 +130,7 @@ enum HashService {
124 130
     // ⚠️ Covers the FULL intended registry (selectedTypeIDs), including types that may have
125 131
     // failed, timed out, or been unauthorized — never filter down to only the successfully-
126 132
     // fetched subset. A query failure must not silently change the registry hash.
127
-    static func typeSetHash(typeIDs: [String]) -> String {
133
+    nonisolated static func typeSetHash(typeIDs: [String]) -> String {
128 134
         let sorted = typeIDs.sorted().joined(separator: "|")
129 135
         let digest = SHA256.hash(data: Data(sorted.utf8))
130 136
         return digest.map { String(format: "%02x", $0) }.joined()
+6 -6
HealthProbe/Services/KeychainService.swift
@@ -5,9 +5,9 @@ import os.log
5 5
 private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "KeychainService")
6 6
 
7 7
 enum KeychainService {
8
-    private static let service = "ro.xdev.healthprobe.deviceid"
9
-    private static let account = "stable_device_id"
10
-    private static var cached: String?
8
+    nonisolated private static let service = "ro.xdev.healthprobe.deviceid"
9
+    nonisolated private static let account = "stable_device_id"
10
+    nonisolated(unsafe) private static var cached: String?
11 11
 
12 12
     struct Resolution {
13 13
         let id: String
@@ -17,7 +17,7 @@ enum KeychainService {
17 17
     // If swiftDataStoreIsEmpty is true and Keychain has an existing ID, the DB was wiped
18 18
     // (reinstall or manual reset) but Keychain survived. We keep the same deviceID but signal
19 19
     // that a new chain must start and is marked recovered.
20
-    static func resolveDeviceID(swiftDataStoreIsEmpty: Bool) -> Resolution {
20
+    nonisolated static func resolveDeviceID(swiftDataStoreIsEmpty: Bool) -> Resolution {
21 21
         if let existing = readFromKeychain() {
22 22
             if swiftDataStoreIsEmpty {
23 23
                 // DB was wiped but Keychain survived — recovered device ID
@@ -30,7 +30,7 @@ enum KeychainService {
30 30
         return Resolution(id: new, isRecovered: false)
31 31
     }
32 32
 
33
-    private static func readFromKeychain() -> String? {
33
+    nonisolated private static func readFromKeychain() -> String? {
34 34
         if let cached { return cached }
35 35
         let query: [String: Any] = [
36 36
             kSecClass as String:            kSecClassGenericPassword,
@@ -50,7 +50,7 @@ enum KeychainService {
50 50
         return string
51 51
     }
52 52
 
53
-    private static func writeToKeychain(_ value: String) {
53
+    nonisolated private static func writeToKeychain(_ value: String) {
54 54
         guard let data = value.data(using: .utf8) else { return }
55 55
         let attributes: [String: Any] = [
56 56
             kSecClass as String:        kSecClassGenericPassword,
+804 -44
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -34,6 +34,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
34 34
         try execute("BEGIN IMMEDIATE TRANSACTION", db: db)
35 35
         do {
36 36
             let summary = try upsertSamples(samples, observedAt: observedAt, db: db)
37
+            try execute("PRAGMA foreign_key_check", db: db)
37 38
             try execute("COMMIT", db: db)
38 39
             return summary
39 40
         } catch {
@@ -66,20 +67,53 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
66 67
         let db = try openDatabase()
67 68
         defer { sqlite3_close(db) }
68 69
         try prepareSchemaIfNeeded(db)
70
+        try execute("BEGIN IMMEDIATE TRANSACTION", db: db)
71
+        do {
72
+            let observationID = try createObservation(
73
+                observedAt: observedMissingAt,
74
+                triggerReason: "deleted_object",
75
+                status: "completed",
76
+                db: db
77
+            )
78
+            if let sampleTypeID = try sampleTypeID(typeIdentifier: sampleTypeIdentifier, db: db),
79
+               let sampleID = try sampleID(sampleUUIDHash: sampleUUIDHash, sampleTypeID: sampleTypeID, db: db) {
80
+                try insertObservationEvent(
81
+                    observationID: observationID,
82
+                    sampleID: sampleID,
83
+                    versionID: nil,
84
+                    eventKind: "disappeared",
85
+                    evidenceKind: "deleted_object",
86
+                    observedAt: observedMissingAt,
87
+                    db: db
88
+                )
89
+                try closeOpenVisibilityRanges(
90
+                    sampleID: sampleID,
91
+                    excludingVersionID: nil,
92
+                    closedAtObservationID: observationID,
93
+                    observedAt: observedMissingAt,
94
+                    db: db
95
+                )
96
+                try rebuildTypeSummary(observationID: observationID, sampleTypeID: sampleTypeID, db: db)
97
+            }
69 98
 
70
-        let sql = """
71
-        UPDATE archive_samples
72
-        SET disappeared_at = ?, last_verified_at = ?
73
-        WHERE sample_uuid_hash = ? AND type_identifier = ?
74
-        """
75
-        try withStatement(sql, db: db) { statement in
76
-            sqlite3_bind_double(statement, 1, observedMissingAt.timeIntervalSinceReferenceDate)
77
-            sqlite3_bind_double(statement, 2, observedMissingAt.timeIntervalSinceReferenceDate)
78
-            bindText(sampleUUIDHash, to: 3, in: statement)
79
-            bindText(sampleTypeIdentifier, to: 4, in: statement)
80
-            guard sqlite3_step(statement) == SQLITE_DONE else {
81
-                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
99
+            let sql = """
100
+            UPDATE archive_samples
101
+            SET disappeared_at = ?, last_verified_at = ?
102
+            WHERE sample_uuid_hash = ? AND type_identifier = ?
103
+            """
104
+            try withStatement(sql, db: db) { statement in
105
+                sqlite3_bind_double(statement, 1, observedMissingAt.timeIntervalSinceReferenceDate)
106
+                sqlite3_bind_double(statement, 2, observedMissingAt.timeIntervalSinceReferenceDate)
107
+                bindText(sampleUUIDHash, to: 3, in: statement)
108
+                bindText(sampleTypeIdentifier, to: 4, in: statement)
109
+                guard sqlite3_step(statement) == SQLITE_DONE else {
110
+                    throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
111
+                }
82 112
             }
113
+            try execute("COMMIT", db: db)
114
+        } catch {
115
+            try? execute("ROLLBACK", db: db)
116
+            throw error
83 117
         }
84 118
     }
85 119
 
@@ -626,6 +660,56 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
626 660
     }
627 661
 
628 662
     private func upsertSamples(_ samples: [HKSample], observedAt: Date, db: OpaquePointer?) throws -> HealthArchiveWriteSummary {
663
+        let rows = samples.map { ArchiveSampleRow(sample: $0, observedAt: observedAt) }
664
+        let observationID = try createObservation(
665
+            observedAt: observedAt,
666
+            triggerReason: "anchored_page",
667
+            status: "completed",
668
+            db: db
669
+        )
670
+
671
+        var inserted = 0
672
+        var updated = 0
673
+        var unchanged = 0
674
+        var touchedTypeIDs = Set<Int64>()
675
+
676
+        try withLegacyArchiveSampleStatement(db: db) { legacyStatement in
677
+            for row in rows {
678
+                let result = try upsertArchiveV2Sample(row, observationID: observationID, db: db)
679
+                touchedTypeIDs.insert(result.sampleTypeID)
680
+                switch result.kind {
681
+                case .inserted:
682
+                    inserted += 1
683
+                case .updated:
684
+                    updated += 1
685
+                case .unchanged:
686
+                    unchanged += 1
687
+                }
688
+
689
+                sqlite3_reset(legacyStatement)
690
+                sqlite3_clear_bindings(legacyStatement)
691
+                bind(row, to: legacyStatement)
692
+                guard sqlite3_step(legacyStatement) == SQLITE_DONE else {
693
+                    throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
694
+                }
695
+            }
696
+        }
697
+
698
+        for sampleTypeID in touchedTypeIDs {
699
+            try rebuildTypeSummary(observationID: observationID, sampleTypeID: sampleTypeID, db: db)
700
+        }
701
+
702
+        return HealthArchiveWriteSummary(
703
+            insertedCount: inserted,
704
+            updatedCount: updated,
705
+            unchangedCount: unchanged
706
+        )
707
+    }
708
+
709
+    private func withLegacyArchiveSampleStatement<T>(
710
+        db: OpaquePointer?,
711
+        body: (OpaquePointer?) throws -> T
712
+    ) throws -> T {
629 713
         let sql = """
630 714
         INSERT INTO archive_samples (
631 715
             sample_uuid_hash, type_identifier, strict_fingerprint, semantic_fingerprint,
@@ -670,32 +754,618 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
670 754
             metadata_json = excluded.metadata_json,
671 755
             archived_at = excluded.archived_at
672 756
         """
757
+        return try withStatement(sql, db: db, body: body)
758
+    }
759
+
760
+    private func upsertArchiveV2Sample(
761
+        _ row: ArchiveSampleRow,
762
+        observationID: Int64,
763
+        db: OpaquePointer?
764
+    ) throws -> ArchiveV2SampleWriteResult {
765
+        let sampleTypeID = try upsertSampleType(typeIdentifier: row.typeIdentifier, db: db)
766
+        let sourceRevisionID = try upsertSourceRevision(row, db: db)
767
+        let deviceID = try upsertDevice(row, db: db)
768
+        let metadataID = try upsertMetadataBlob(row, db: db)
769
+        let sampleResult = try upsertSample(row, sampleTypeID: sampleTypeID, observationID: observationID, db: db)
770
+        let versionResult = try upsertSampleVersion(
771
+            row,
772
+            sampleID: sampleResult.id,
773
+            sourceRevisionID: sourceRevisionID,
774
+            deviceID: deviceID,
775
+            metadataID: metadataID,
776
+            observationID: observationID,
777
+            db: db
778
+        )
779
+
780
+        let writeKind: ArchiveV2SampleWriteKind
781
+        let eventKind: String
782
+        if sampleResult.inserted {
783
+            writeKind = .inserted
784
+            eventKind = "appeared"
785
+        } else if versionResult.inserted {
786
+            writeKind = .updated
787
+            eventKind = "representationChanged"
788
+        } else {
789
+            writeKind = .unchanged
790
+            eventKind = "verified"
791
+        }
792
+
793
+        try insertObservationEvent(
794
+            observationID: observationID,
795
+            sampleID: sampleResult.id,
796
+            versionID: versionResult.id,
797
+            eventKind: eventKind,
798
+            evidenceKind: "healthkit_sample",
799
+            observedAt: row.observedAt,
800
+            db: db
801
+        )
802
+        try closeOpenVisibilityRanges(
803
+            sampleID: sampleResult.id,
804
+            excludingVersionID: versionResult.id,
805
+            closedAtObservationID: observationID,
806
+            observedAt: row.observedAt,
807
+            db: db
808
+        )
809
+        try insertOpenVisibilityRangeIfNeeded(
810
+            sampleID: sampleResult.id,
811
+            versionID: versionResult.id,
812
+            observationID: observationID,
813
+            observedAt: row.observedAt,
814
+            db: db
815
+        )
816
+
817
+        return ArchiveV2SampleWriteResult(sampleTypeID: sampleTypeID, kind: writeKind)
818
+    }
819
+
820
+    private func createObservation(
821
+        observedAt: Date,
822
+        triggerReason: String,
823
+        status: String,
824
+        db: OpaquePointer?
825
+    ) throws -> Int64 {
826
+        let deviceChainID = try upsertCurrentDeviceChain(db)
827
+        let timeZone = TimeZone.current
828
+        let sql = """
829
+        INSERT INTO observations (
830
+            device_chain_id, observed_at, started_at, ended_at, status, trigger_reason,
831
+            app_version, os_version, time_zone_identifier, time_zone_seconds_from_gmt,
832
+            schema_version, selected_type_set_hash, notes
833
+        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
834
+        """
835
+        try withStatement(sql, db: db) { statement in
836
+            bindInt64(deviceChainID, to: 1, in: statement)
837
+            sqlite3_bind_double(statement, 2, observedAt.timeIntervalSince1970)
838
+            sqlite3_bind_double(statement, 3, observedAt.timeIntervalSince1970)
839
+            sqlite3_bind_double(statement, 4, Date().timeIntervalSince1970)
840
+            bindText(status, to: 5, in: statement)
841
+            bindText(triggerReason, to: 6, in: statement)
842
+            bindText(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, to: 7, in: statement)
843
+            bindText(ProcessInfo.processInfo.operatingSystemVersionString, to: 8, in: statement)
844
+            bindText(timeZone.identifier, to: 9, in: statement)
845
+            sqlite3_bind_int(statement, 10, Int32(timeZone.secondsFromGMT(for: observedAt)))
846
+            bindInt(Self.archiveSchemaVersion, to: 11, in: statement)
847
+            guard sqlite3_step(statement) == SQLITE_DONE else {
848
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
849
+            }
850
+        }
851
+        return sqlite3_last_insert_rowid(db)
852
+    }
853
+
854
+    private func upsertCurrentDeviceChain(_ db: OpaquePointer?) throws -> Int64 {
855
+        let resolution = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false)
856
+        let chainHash = HashService.archiveContentHash(domain: "hp:v2:device_chain", parts: [resolution.id])
857
+        try withStatement(
858
+            "INSERT OR IGNORE INTO device_chains (device_chain_hash, created_at, recovered_from_keychain) VALUES (?, ?, ?)",
859
+            db: db
860
+        ) { statement in
861
+            bindText(chainHash, to: 1, in: statement)
862
+            sqlite3_bind_double(statement, 2, Date().timeIntervalSince1970)
863
+            sqlite3_bind_int(statement, 3, resolution.isRecovered ? 1 : 0)
864
+            guard sqlite3_step(statement) == SQLITE_DONE else {
865
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
866
+            }
867
+        }
868
+        return try requiredInt64(
869
+            "SELECT id FROM device_chains WHERE device_chain_hash = ? LIMIT 1",
870
+            db: db
871
+        ) { statement in
872
+            bindText(chainHash, to: 1, in: statement)
873
+        }
874
+    }
875
+
876
+    private func upsertSampleType(typeIdentifier: String, db: OpaquePointer?) throws -> Int64 {
877
+        try withStatement(
878
+            "INSERT OR IGNORE INTO sample_types (type_identifier, display_name, category) VALUES (?, NULL, NULL)",
879
+            db: db
880
+        ) { statement in
881
+            bindText(typeIdentifier, to: 1, in: statement)
882
+            guard sqlite3_step(statement) == SQLITE_DONE else {
883
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
884
+            }
885
+        }
886
+        return try requiredInt64("SELECT id FROM sample_types WHERE type_identifier = ? LIMIT 1", db: db) { statement in
887
+            bindText(typeIdentifier, to: 1, in: statement)
888
+        }
889
+    }
890
+
891
+    private func sampleTypeID(typeIdentifier: String, db: OpaquePointer?) throws -> Int64? {
892
+        try optionalInt64("SELECT id FROM sample_types WHERE type_identifier = ? LIMIT 1", db: db) { statement in
893
+            bindText(typeIdentifier, to: 1, in: statement)
894
+        }
895
+    }
673 896
 
897
+    private func upsertSourceRevision(_ row: ArchiveSampleRow, db: OpaquePointer?) throws -> Int64? {
898
+        guard row.sourceName != nil || row.sourceBundleIdentifier != nil else { return nil }
899
+        let sourceNameHash = row.sourceName.map { HashService.archiveContentHash(domain: "hp:v2:source_name", parts: [$0]) }
900
+        let sourceID = try upsertSource(sourceNameHash: sourceNameHash, bundleIdentifier: row.sourceBundleIdentifier, db: db)
901
+        try withStatement(
902
+            "INSERT OR IGNORE INTO source_revisions (source_id, product_type, version, operating_system_version) VALUES (?, ?, ?, ?)",
903
+            db: db
904
+        ) { statement in
905
+            bindInt64(sourceID, to: 1, in: statement)
906
+            bindText(row.sourceProductType, to: 2, in: statement)
907
+            bindText(row.sourceVersion, to: 3, in: statement)
908
+            bindText(row.sourceOperatingSystemVersion, to: 4, in: statement)
909
+            guard sqlite3_step(statement) == SQLITE_DONE else {
910
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
911
+            }
912
+        }
913
+        return try requiredInt64(
914
+            """
915
+            SELECT id FROM source_revisions
916
+            WHERE source_id = ?
917
+              AND (product_type = ? OR (product_type IS NULL AND ? IS NULL))
918
+              AND (version = ? OR (version IS NULL AND ? IS NULL))
919
+              AND (operating_system_version = ? OR (operating_system_version IS NULL AND ? IS NULL))
920
+            LIMIT 1
921
+            """,
922
+            db: db
923
+        ) { statement in
924
+            bindInt64(sourceID, to: 1, in: statement)
925
+            bindText(row.sourceProductType, to: 2, in: statement)
926
+            bindText(row.sourceProductType, to: 3, in: statement)
927
+            bindText(row.sourceVersion, to: 4, in: statement)
928
+            bindText(row.sourceVersion, to: 5, in: statement)
929
+            bindText(row.sourceOperatingSystemVersion, to: 6, in: statement)
930
+            bindText(row.sourceOperatingSystemVersion, to: 7, in: statement)
931
+        }
932
+    }
933
+
934
+    private func upsertSource(sourceNameHash: String?, bundleIdentifier: String?, db: OpaquePointer?) throws -> Int64 {
935
+        if let existing = try optionalInt64(
936
+            """
937
+            SELECT id FROM sources
938
+            WHERE (source_name_hash = ? OR (source_name_hash IS NULL AND ? IS NULL))
939
+              AND (bundle_identifier = ? OR (bundle_identifier IS NULL AND ? IS NULL))
940
+            LIMIT 1
941
+            """,
942
+            db: db
943
+        ) { statement in
944
+            bindText(sourceNameHash, to: 1, in: statement)
945
+            bindText(sourceNameHash, to: 2, in: statement)
946
+            bindText(bundleIdentifier, to: 3, in: statement)
947
+            bindText(bundleIdentifier, to: 4, in: statement)
948
+        } {
949
+            return existing
950
+        }
951
+        try withStatement("INSERT INTO sources (source_name_hash, bundle_identifier) VALUES (?, ?)", db: db) { statement in
952
+            bindText(sourceNameHash, to: 1, in: statement)
953
+            bindText(bundleIdentifier, to: 2, in: statement)
954
+            guard sqlite3_step(statement) == SQLITE_DONE else {
955
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
956
+            }
957
+        }
958
+        return sqlite3_last_insert_rowid(db)
959
+    }
960
+
961
+    private func upsertDevice(_ row: ArchiveSampleRow, db: OpaquePointer?) throws -> Int64? {
962
+        guard row.hasDeviceProvenance else { return nil }
963
+        let deviceHash = row.deviceName.map { HashService.archiveContentHash(domain: "hp:v2:device_name", parts: [$0]) }
964
+        let manufacturerHash = row.deviceManufacturer.map { HashService.archiveContentHash(domain: "hp:v2:device_manufacturer", parts: [$0]) }
965
+        let localIdentifierHash = row.deviceLocalIdentifier.map { HashService.archiveContentHash(domain: "hp:v2:device_local_id", parts: [$0]) }
966
+        let udiHash = row.deviceUDI.map { HashService.archiveContentHash(domain: "hp:v2:device_udi", parts: [$0]) }
967
+
968
+        if let existing = try optionalInt64(
969
+            """
970
+            SELECT id FROM hk_devices
971
+            WHERE (device_hash = ? OR (device_hash IS NULL AND ? IS NULL))
972
+              AND (local_identifier_hash = ? OR (local_identifier_hash IS NULL AND ? IS NULL))
973
+              AND (udi_hash = ? OR (udi_hash IS NULL AND ? IS NULL))
974
+              AND (model = ? OR (model IS NULL AND ? IS NULL))
975
+            LIMIT 1
976
+            """,
977
+            db: db
978
+        ) { statement in
979
+            bindText(deviceHash, to: 1, in: statement)
980
+            bindText(deviceHash, to: 2, in: statement)
981
+            bindText(localIdentifierHash, to: 3, in: statement)
982
+            bindText(localIdentifierHash, to: 4, in: statement)
983
+            bindText(udiHash, to: 5, in: statement)
984
+            bindText(udiHash, to: 6, in: statement)
985
+            bindText(row.deviceModel, to: 7, in: statement)
986
+            bindText(row.deviceModel, to: 8, in: statement)
987
+        } {
988
+            return existing
989
+        }
990
+
991
+        try withStatement(
992
+            """
993
+            INSERT INTO hk_devices (
994
+                device_hash, manufacturer_hash, model, hardware_version, firmware_version,
995
+                software_version, local_identifier_hash, udi_hash
996
+            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
997
+            """,
998
+            db: db
999
+        ) { statement in
1000
+            bindText(deviceHash, to: 1, in: statement)
1001
+            bindText(manufacturerHash, to: 2, in: statement)
1002
+            bindText(row.deviceModel, to: 3, in: statement)
1003
+            bindText(row.deviceHardwareVersion, to: 4, in: statement)
1004
+            bindText(row.deviceFirmwareVersion, to: 5, in: statement)
1005
+            bindText(row.deviceSoftwareVersion, to: 6, in: statement)
1006
+            bindText(localIdentifierHash, to: 7, in: statement)
1007
+            bindText(udiHash, to: 8, in: statement)
1008
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1009
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1010
+            }
1011
+        }
1012
+        return sqlite3_last_insert_rowid(db)
1013
+    }
1014
+
1015
+    private func upsertMetadataBlob(_ row: ArchiveSampleRow, db: OpaquePointer?) throws -> Int64? {
1016
+        guard let metadataHash = row.metadataHash, let metadataJSON = row.metadataJSON else { return nil }
1017
+        try withStatement(
1018
+            "INSERT OR IGNORE INTO metadata_blobs (metadata_hash, metadata_json) VALUES (?, ?)",
1019
+            db: db
1020
+        ) { statement in
1021
+            bindText(metadataHash, to: 1, in: statement)
1022
+            bindText(metadataJSON, to: 2, in: statement)
1023
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1024
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1025
+            }
1026
+        }
1027
+        return try requiredInt64("SELECT id FROM metadata_blobs WHERE metadata_hash = ? LIMIT 1", db: db) { statement in
1028
+            bindText(metadataHash, to: 1, in: statement)
1029
+        }
1030
+    }
1031
+
1032
+    private func upsertSample(
1033
+        _ row: ArchiveSampleRow,
1034
+        sampleTypeID: Int64,
1035
+        observationID: Int64,
1036
+        db: OpaquePointer?
1037
+    ) throws -> (id: Int64, inserted: Bool) {
1038
+        try withStatement(
1039
+            """
1040
+            INSERT OR IGNORE INTO samples (
1041
+                sample_type_id, sample_uuid_hash, strict_fingerprint, semantic_fingerprint,
1042
+                fuzzy_key, first_seen_observation_id, first_seen_at
1043
+            ) VALUES (?, ?, ?, ?, NULL, ?, ?)
1044
+            """,
1045
+            db: db
1046
+        ) { statement in
1047
+            bindInt64(sampleTypeID, to: 1, in: statement)
1048
+            bindText(row.sampleUUIDHash, to: 2, in: statement)
1049
+            bindText(row.strictFingerprint, to: 3, in: statement)
1050
+            bindText(row.semanticFingerprint, to: 4, in: statement)
1051
+            bindInt64(observationID, to: 5, in: statement)
1052
+            sqlite3_bind_double(statement, 6, row.observedAt.timeIntervalSince1970)
1053
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1054
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1055
+            }
1056
+        }
1057
+        let inserted = sqlite3_changes(db) > 0
1058
+        let id = try requiredInt64(
1059
+            "SELECT id FROM samples WHERE sample_type_id = ? AND strict_fingerprint = ? LIMIT 1",
1060
+            db: db
1061
+        ) { statement in
1062
+            bindInt64(sampleTypeID, to: 1, in: statement)
1063
+            bindText(row.strictFingerprint, to: 2, in: statement)
1064
+        }
1065
+        return (id, inserted)
1066
+    }
1067
+
1068
+    private func sampleID(sampleUUIDHash: String, sampleTypeID: Int64, db: OpaquePointer?) throws -> Int64? {
1069
+        try optionalInt64(
1070
+            "SELECT id FROM samples WHERE sample_type_id = ? AND sample_uuid_hash = ? LIMIT 1",
1071
+            db: db
1072
+        ) { statement in
1073
+            bindInt64(sampleTypeID, to: 1, in: statement)
1074
+            bindText(sampleUUIDHash, to: 2, in: statement)
1075
+        }
1076
+    }
1077
+
1078
+    private func upsertSampleVersion(
1079
+        _ row: ArchiveSampleRow,
1080
+        sampleID: Int64,
1081
+        sourceRevisionID: Int64?,
1082
+        deviceID: Int64?,
1083
+        metadataID: Int64?,
1084
+        observationID: Int64,
1085
+        db: OpaquePointer?
1086
+    ) throws -> (id: Int64, inserted: Bool) {
1087
+        try withStatement(
1088
+            """
1089
+            INSERT OR IGNORE INTO sample_versions (
1090
+                sample_id, payload_hash, start_date, end_date, value_kind, numeric_value,
1091
+                unit, category_value, workout_activity_type, duration_seconds,
1092
+                source_revision_id, hk_device_id, metadata_id, created_observation_id
1093
+            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1094
+            """,
1095
+            db: db
1096
+        ) { statement in
1097
+            bindInt64(sampleID, to: 1, in: statement)
1098
+            bindText(row.payloadHash, to: 2, in: statement)
1099
+            sqlite3_bind_double(statement, 3, row.startDate.timeIntervalSince1970)
1100
+            sqlite3_bind_double(statement, 4, row.endDate.timeIntervalSince1970)
1101
+            bindText(row.valueKind, to: 5, in: statement)
1102
+            bindDouble(row.value, to: 6, in: statement)
1103
+            bindText(row.unit, to: 7, in: statement)
1104
+            bindInt(row.categoryValue, to: 8, in: statement)
1105
+            bindInt(row.workoutActivityType, to: 9, in: statement)
1106
+            bindDouble(row.durationSeconds, to: 10, in: statement)
1107
+            bindInt64(sourceRevisionID, to: 11, in: statement)
1108
+            bindInt64(deviceID, to: 12, in: statement)
1109
+            bindInt64(metadataID, to: 13, in: statement)
1110
+            bindInt64(observationID, to: 14, in: statement)
1111
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1112
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1113
+            }
1114
+        }
1115
+        let inserted = sqlite3_changes(db) > 0
1116
+        let id = try requiredInt64(
1117
+            "SELECT id FROM sample_versions WHERE sample_id = ? AND payload_hash = ? LIMIT 1",
1118
+            db: db
1119
+        ) { statement in
1120
+            bindInt64(sampleID, to: 1, in: statement)
1121
+            bindText(row.payloadHash, to: 2, in: statement)
1122
+        }
1123
+        return (id, inserted)
1124
+    }
1125
+
1126
+    private func insertObservationEvent(
1127
+        observationID: Int64,
1128
+        sampleID: Int64,
1129
+        versionID: Int64?,
1130
+        eventKind: String,
1131
+        evidenceKind: String,
1132
+        observedAt: Date,
1133
+        db: OpaquePointer?
1134
+    ) throws {
1135
+        try withStatement(
1136
+            """
1137
+            INSERT OR IGNORE INTO sample_observation_events (
1138
+                observation_id, sample_id, version_id, event_kind, observed_at, evidence_kind
1139
+            ) VALUES (?, ?, ?, ?, ?, ?)
1140
+            """,
1141
+            db: db
1142
+        ) { statement in
1143
+            bindInt64(observationID, to: 1, in: statement)
1144
+            bindInt64(sampleID, to: 2, in: statement)
1145
+            bindInt64(versionID, to: 3, in: statement)
1146
+            bindText(eventKind, to: 4, in: statement)
1147
+            sqlite3_bind_double(statement, 5, observedAt.timeIntervalSince1970)
1148
+            bindText(evidenceKind, to: 6, in: statement)
1149
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1150
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1151
+            }
1152
+        }
1153
+    }
1154
+
1155
+    private func closeOpenVisibilityRanges(
1156
+        sampleID: Int64,
1157
+        excludingVersionID: Int64?,
1158
+        closedAtObservationID: Int64,
1159
+        observedAt: Date,
1160
+        db: OpaquePointer?
1161
+    ) throws {
1162
+        let versionPredicate = excludingVersionID == nil
1163
+            ? ""
1164
+            : "AND (version_id IS NULL OR version_id != ?)"
1165
+        let sql = """
1166
+        UPDATE sample_visibility_ranges
1167
+        SET last_observation_id = ?, last_seen_at = ?
1168
+        WHERE sample_id = ? AND last_observation_id IS NULL \(versionPredicate)
1169
+        """
1170
+        try withStatement(sql, db: db) { statement in
1171
+            bindInt64(closedAtObservationID, to: 1, in: statement)
1172
+            sqlite3_bind_double(statement, 2, observedAt.timeIntervalSince1970)
1173
+            bindInt64(sampleID, to: 3, in: statement)
1174
+            if let excludingVersionID {
1175
+                bindInt64(excludingVersionID, to: 4, in: statement)
1176
+            }
1177
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1178
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1179
+            }
1180
+        }
1181
+    }
1182
+
1183
+    private func insertOpenVisibilityRangeIfNeeded(
1184
+        sampleID: Int64,
1185
+        versionID: Int64,
1186
+        observationID: Int64,
1187
+        observedAt: Date,
1188
+        db: OpaquePointer?
1189
+    ) throws {
1190
+        let existing = try optionalInt64(
1191
+            """
1192
+            SELECT first_observation_id
1193
+            FROM sample_visibility_ranges
1194
+            WHERE sample_id = ? AND version_id = ? AND last_observation_id IS NULL
1195
+            LIMIT 1
1196
+            """,
1197
+            db: db
1198
+        ) { statement in
1199
+            bindInt64(sampleID, to: 1, in: statement)
1200
+            bindInt64(versionID, to: 2, in: statement)
1201
+        }
1202
+        guard existing == nil else { return }
1203
+        try withStatement(
1204
+            """
1205
+            INSERT OR IGNORE INTO sample_visibility_ranges (
1206
+                sample_id, version_id, first_observation_id, last_observation_id, first_seen_at, last_seen_at
1207
+            ) VALUES (?, ?, ?, NULL, ?, NULL)
1208
+            """,
1209
+            db: db
1210
+        ) { statement in
1211
+            bindInt64(sampleID, to: 1, in: statement)
1212
+            bindInt64(versionID, to: 2, in: statement)
1213
+            bindInt64(observationID, to: 3, in: statement)
1214
+            sqlite3_bind_double(statement, 4, observedAt.timeIntervalSince1970)
1215
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1216
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1217
+            }
1218
+        }
1219
+    }
1220
+
1221
+    private func rebuildTypeSummary(observationID: Int64, sampleTypeID: Int64, db: OpaquePointer?) throws {
1222
+        let summary = try typeSummary(observationID: observationID, sampleTypeID: sampleTypeID, db: db)
1223
+        let aggregateParts: [String?] = [
1224
+            String(observationID),
1225
+            String(sampleTypeID),
1226
+            String(summary.visibleRecordCount),
1227
+            String(summary.appearedCount),
1228
+            String(summary.disappearedCount),
1229
+            String(summary.representationChangedCount),
1230
+            summary.earliestStartDate.map { String($0) },
1231
+            summary.latestEndDate.map { String($0) },
1232
+            summary.valueSum.map { String(format: "%.17g", $0) },
1233
+            summary.valueMax.map { String(format: "%.17g", $0) }
1234
+        ]
1235
+        let aggregateHash = HashService.archiveContentHash(
1236
+            domain: "hp:v2:type_summary",
1237
+            parts: aggregateParts
1238
+        )
1239
+        try withStatement(
1240
+            """
1241
+            INSERT OR REPLACE INTO observation_type_summaries (
1242
+                observation_id, sample_type_id, visible_record_count, appeared_count,
1243
+                disappeared_count, representation_changed_count, earliest_start_date,
1244
+                latest_end_date, value_sum, value_max, aggregate_hash
1245
+            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1246
+            """,
1247
+            db: db
1248
+        ) { statement in
1249
+            bindInt64(observationID, to: 1, in: statement)
1250
+            bindInt64(sampleTypeID, to: 2, in: statement)
1251
+            bindInt(summary.visibleRecordCount, to: 3, in: statement)
1252
+            bindInt(summary.appearedCount, to: 4, in: statement)
1253
+            bindInt(summary.disappearedCount, to: 5, in: statement)
1254
+            bindInt(summary.representationChangedCount, to: 6, in: statement)
1255
+            bindDouble(summary.earliestStartDate, to: 7, in: statement)
1256
+            bindDouble(summary.latestEndDate, to: 8, in: statement)
1257
+            bindDouble(summary.valueSum, to: 9, in: statement)
1258
+            bindDouble(summary.valueMax, to: 10, in: statement)
1259
+            bindText(aggregateHash, to: 11, in: statement)
1260
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1261
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1262
+            }
1263
+        }
1264
+    }
1265
+
1266
+    private func typeSummary(observationID: Int64, sampleTypeID: Int64, db: OpaquePointer?) throws -> ArchiveV2TypeSummary {
1267
+        let counts = try eventCounts(observationID: observationID, sampleTypeID: sampleTypeID, db: db)
1268
+        let aggregate = try visibleAggregate(sampleTypeID: sampleTypeID, db: db)
1269
+        return ArchiveV2TypeSummary(
1270
+            visibleRecordCount: aggregate.visibleRecordCount,
1271
+            appearedCount: counts.appeared,
1272
+            disappearedCount: counts.disappeared,
1273
+            representationChangedCount: counts.representationChanged,
1274
+            earliestStartDate: aggregate.earliestStartDate,
1275
+            latestEndDate: aggregate.latestEndDate,
1276
+            valueSum: aggregate.valueSum,
1277
+            valueMax: aggregate.valueMax
1278
+        )
1279
+    }
1280
+
1281
+    private func eventCounts(
1282
+        observationID: Int64,
1283
+        sampleTypeID: Int64,
1284
+        db: OpaquePointer?
1285
+    ) throws -> (appeared: Int, disappeared: Int, representationChanged: Int) {
1286
+        let sql = """
1287
+        SELECT event_kind, COUNT(*)
1288
+        FROM sample_observation_events e
1289
+        JOIN samples s ON s.id = e.sample_id
1290
+        WHERE e.observation_id = ? AND s.sample_type_id = ?
1291
+        GROUP BY event_kind
1292
+        """
674 1293
         return try withStatement(sql, db: db) { statement in
675
-            var inserted = 0
676
-            var updated = 0
677
-            for sample in samples {
678
-                sqlite3_reset(statement)
679
-                sqlite3_clear_bindings(statement)
680
-                let row = ArchiveSampleRow(sample: sample, observedAt: observedAt)
681
-                bind(row, to: statement)
682
-                guard sqlite3_step(statement) == SQLITE_DONE else {
683
-                    throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
684
-                }
685
-                if sqlite3_changes(db) == 1 {
686
-                    inserted += 1
687
-                } else {
688
-                    updated += 1
1294
+            bindInt64(observationID, to: 1, in: statement)
1295
+            bindInt64(sampleTypeID, to: 2, in: statement)
1296
+            var appeared = 0
1297
+            var disappeared = 0
1298
+            var representationChanged = 0
1299
+            while sqlite3_step(statement) == SQLITE_ROW {
1300
+                let kind = columnText(statement, 0)
1301
+                let count = columnInt(statement, 1) ?? 0
1302
+                switch kind {
1303
+                case "appeared":
1304
+                    appeared = count
1305
+                case "disappeared":
1306
+                    disappeared = count
1307
+                case "representationChanged":
1308
+                    representationChanged = count
1309
+                default:
1310
+                    break
689 1311
                 }
690 1312
             }
691
-            return HealthArchiveWriteSummary(
692
-                insertedCount: inserted,
693
-                updatedCount: updated,
694
-                unchangedCount: max(0, samples.count - inserted - updated)
1313
+            return (appeared, disappeared, representationChanged)
1314
+        }
1315
+    }
1316
+
1317
+    private func visibleAggregate(sampleTypeID: Int64, db: OpaquePointer?) throws -> ArchiveV2VisibleAggregate {
1318
+        let sql = """
1319
+        SELECT COUNT(*), MIN(v.start_date), MAX(v.end_date), SUM(v.numeric_value), MAX(v.numeric_value)
1320
+        FROM sample_visibility_ranges r
1321
+        JOIN samples s ON s.id = r.sample_id
1322
+        JOIN sample_versions v ON v.id = r.version_id
1323
+        WHERE s.sample_type_id = ? AND r.last_observation_id IS NULL
1324
+        """
1325
+        return try withStatement(sql, db: db) { statement in
1326
+            bindInt64(sampleTypeID, to: 1, in: statement)
1327
+            guard sqlite3_step(statement) == SQLITE_ROW else {
1328
+                return ArchiveV2VisibleAggregate(
1329
+                    visibleRecordCount: 0,
1330
+                    earliestStartDate: nil,
1331
+                    latestEndDate: nil,
1332
+                    valueSum: nil,
1333
+                    valueMax: nil
1334
+                )
1335
+            }
1336
+            return ArchiveV2VisibleAggregate(
1337
+                visibleRecordCount: columnInt(statement, 0) ?? 0,
1338
+                earliestStartDate: columnDouble(statement, 1),
1339
+                latestEndDate: columnDouble(statement, 2),
1340
+                valueSum: columnDouble(statement, 3),
1341
+                valueMax: columnDouble(statement, 4)
695 1342
             )
696 1343
         }
697 1344
     }
698 1345
 
1346
+    private func requiredInt64(
1347
+        _ sql: String,
1348
+        db: OpaquePointer?,
1349
+        bind: (OpaquePointer?) throws -> Void
1350
+    ) throws -> Int64 {
1351
+        guard let value = try optionalInt64(sql, db: db, bind: bind) else {
1352
+            throw SQLiteHealthArchiveStoreError.stepFailed("missing required row")
1353
+        }
1354
+        return value
1355
+    }
1356
+
1357
+    private func optionalInt64(
1358
+        _ sql: String,
1359
+        db: OpaquePointer?,
1360
+        bind: (OpaquePointer?) throws -> Void
1361
+    ) throws -> Int64? {
1362
+        try withStatement(sql, db: db) { statement in
1363
+            try bind(statement)
1364
+            guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
1365
+            return sqlite3_column_int64(statement, 0)
1366
+        }
1367
+    }
1368
+
699 1369
     private func execute(_ sql: String, db: OpaquePointer?) throws {
700 1370
         guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else {
701 1371
             throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
@@ -750,6 +1420,7 @@ private struct ArchiveSampleRow {
750 1420
     let typeIdentifier: String
751 1421
     let strictFingerprint: String
752 1422
     let semanticFingerprint: String
1423
+    let payloadHash: String
753 1424
     let startDate: Date
754 1425
     let endDate: Date
755 1426
     let observedAt: Date
@@ -773,6 +1444,18 @@ private struct ArchiveSampleRow {
773 1444
     let deviceLocalIdentifier: String?
774 1445
     let deviceUDI: String?
775 1446
     let metadataJSON: String?
1447
+    let metadataHash: String?
1448
+
1449
+    nonisolated var hasDeviceProvenance: Bool {
1450
+        deviceName != nil ||
1451
+        deviceManufacturer != nil ||
1452
+        deviceModel != nil ||
1453
+        deviceHardwareVersion != nil ||
1454
+        deviceFirmwareVersion != nil ||
1455
+        deviceSoftwareVersion != nil ||
1456
+        deviceLocalIdentifier != nil ||
1457
+        deviceUDI != nil
1458
+    }
776 1459
 
777 1460
     nonisolated init(sample: HKSample, observedAt: Date) {
778 1461
         let sampleUUID = sample.uuid.uuidString
@@ -782,6 +1465,15 @@ private struct ArchiveSampleRow {
782 1465
         let workout = sample as? HKWorkout
783 1466
         let sourceRevision = sample.sourceRevision
784 1467
         let device = sample.device
1468
+        let valueKind = quantity?.kind ?? (category == nil ? (workout == nil ? nil : "workout") : "category")
1469
+        let numericValue = quantity?.value
1470
+        let unit = quantity?.unit
1471
+        let categoryValue = category?.value
1472
+        let workoutActivityType = workout.map { Int($0.workoutActivityType.rawValue) }
1473
+        let durationSeconds = workout?.duration
1474
+        let sourceOperatingSystemVersion = ArchiveSampleRow.operatingSystemVersionString(sourceRevision.operatingSystemVersion)
1475
+        let metadataJSON = ArchiveSampleRow.metadataJSONString(sample.metadata)
1476
+        let metadataHash = metadataJSON.map { HashService.archiveContentHash(domain: "hp:v2:metadata", parts: [$0]) }
785 1477
 
786 1478
         self.sampleUUIDHash = HashService.sampleUUIDHash(sampleUUID)
787 1479
         self.typeIdentifier = typeIdentifier
@@ -795,26 +1487,23 @@ private struct ArchiveSampleRow {
795 1487
             typeIdentifier: typeIdentifier,
796 1488
             startDate: sample.startDate,
797 1489
             endDate: sample.endDate,
798
-            value: quantity?.value,
799
-            unit: quantity?.unit,
800
-            categoryValue: category?.value,
1490
+            value: numericValue,
1491
+            unit: unit,
1492
+            categoryValue: categoryValue,
801 1493
             workoutActivityType: workout?.workoutActivityType.rawValue,
802 1494
             sourceBundleIdentifier: sourceRevision.source.bundleIdentifier
803 1495
         )
804
-        self.startDate = sample.startDate
805
-        self.endDate = sample.endDate
806
-        self.observedAt = observedAt
807
-        self.valueKind = quantity?.kind ?? (category == nil ? (workout == nil ? nil : "workout") : "category")
808
-        self.value = quantity?.value
809
-        self.unit = quantity?.unit
810
-        self.categoryValue = category?.value
811
-        self.workoutActivityType = workout.map { Int($0.workoutActivityType.rawValue) }
812
-        self.durationSeconds = workout?.duration
1496
+        self.valueKind = valueKind
1497
+        self.value = numericValue
1498
+        self.unit = unit
1499
+        self.categoryValue = categoryValue
1500
+        self.workoutActivityType = workoutActivityType
1501
+        self.durationSeconds = durationSeconds
813 1502
         self.sourceName = sourceRevision.source.name
814 1503
         self.sourceBundleIdentifier = sourceRevision.source.bundleIdentifier
815 1504
         self.sourceProductType = sourceRevision.productType
816 1505
         self.sourceVersion = sourceRevision.version
817
-        self.sourceOperatingSystemVersion = ArchiveSampleRow.operatingSystemVersionString(sourceRevision.operatingSystemVersion)
1506
+        self.sourceOperatingSystemVersion = sourceOperatingSystemVersion
818 1507
         self.deviceName = device?.name
819 1508
         self.deviceManufacturer = device?.manufacturer
820 1509
         self.deviceModel = device?.model
@@ -823,7 +1512,40 @@ private struct ArchiveSampleRow {
823 1512
         self.deviceSoftwareVersion = device?.softwareVersion
824 1513
         self.deviceLocalIdentifier = device?.localIdentifier
825 1514
         self.deviceUDI = device?.udiDeviceIdentifier
826
-        self.metadataJSON = ArchiveSampleRow.metadataJSONString(sample.metadata)
1515
+        self.metadataJSON = metadataJSON
1516
+        self.metadataHash = metadataHash
1517
+        self.payloadHash = HashService.archiveContentHash(
1518
+            domain: "hp:v2:payload",
1519
+            parts: [
1520
+                typeIdentifier,
1521
+                ArchiveSampleRow.timestampString(sample.startDate),
1522
+                ArchiveSampleRow.timestampString(sample.endDate),
1523
+                valueKind,
1524
+                numericValue.map { String(format: "%.17g", $0) },
1525
+                unit,
1526
+                categoryValue.map(String.init),
1527
+                workoutActivityType.map(String.init),
1528
+                durationSeconds.map { String(format: "%.17g", $0) },
1529
+                sourceRevision.source.bundleIdentifier,
1530
+                sourceRevision.productType,
1531
+                sourceRevision.version,
1532
+                ArchiveSampleRow.operatingSystemVersionString(sourceRevision.operatingSystemVersion),
1533
+                device?.model,
1534
+                device?.hardwareVersion,
1535
+                device?.firmwareVersion,
1536
+                device?.softwareVersion,
1537
+                device?.localIdentifier,
1538
+                device?.udiDeviceIdentifier,
1539
+                metadataHash
1540
+            ]
1541
+        )
1542
+        self.startDate = sample.startDate
1543
+        self.endDate = sample.endDate
1544
+        self.observedAt = observedAt
1545
+    }
1546
+
1547
+    nonisolated private static func timestampString(_ date: Date) -> String {
1548
+        String(format: "%.6f", date.timeIntervalSince1970)
827 1549
     }
828 1550
 
829 1551
     nonisolated private static func quantityPayload(_ sample: HKSample) -> (kind: String, value: Double, unit: String)? {
@@ -890,6 +1612,36 @@ private struct ArchiveSampleRow {
890 1612
     }
891 1613
 }
892 1614
 
1615
+private enum ArchiveV2SampleWriteKind {
1616
+    case inserted
1617
+    case updated
1618
+    case unchanged
1619
+}
1620
+
1621
+private struct ArchiveV2SampleWriteResult {
1622
+    let sampleTypeID: Int64
1623
+    let kind: ArchiveV2SampleWriteKind
1624
+}
1625
+
1626
+private struct ArchiveV2TypeSummary {
1627
+    let visibleRecordCount: Int
1628
+    let appearedCount: Int
1629
+    let disappearedCount: Int
1630
+    let representationChangedCount: Int
1631
+    let earliestStartDate: Double?
1632
+    let latestEndDate: Double?
1633
+    let valueSum: Double?
1634
+    let valueMax: Double?
1635
+}
1636
+
1637
+private struct ArchiveV2VisibleAggregate {
1638
+    let visibleRecordCount: Int
1639
+    let earliestStartDate: Double?
1640
+    let latestEndDate: Double?
1641
+    let valueSum: Double?
1642
+    let valueMax: Double?
1643
+}
1644
+
893 1645
 nonisolated private struct HealthArchiveReportPayload: Encodable {
894 1646
     let reportID: UUID
895 1647
     let title: String
@@ -930,6 +1682,14 @@ nonisolated private func bindInt(_ value: Int?, to index: Int32, in statement: O
930 1682
     sqlite3_bind_int64(statement, index, sqlite3_int64(value))
931 1683
 }
932 1684
 
1685
+nonisolated private func bindInt64(_ value: Int64?, to index: Int32, in statement: OpaquePointer?) {
1686
+    guard let value else {
1687
+        sqlite3_bind_null(statement, index)
1688
+        return
1689
+    }
1690
+    sqlite3_bind_int64(statement, index, sqlite3_int64(value))
1691
+}
1692
+
933 1693
 nonisolated private func columnText(_ statement: OpaquePointer?, _ index: Int32) -> String? {
934 1694
     guard sqlite3_column_type(statement, index) != SQLITE_NULL,
935 1695
           let pointer = sqlite3_column_text(statement, index) else {