Showing 6 changed files with 389 additions and 35 deletions
+6 -0
HealthProbe/Doc/00-agent-guides/AGENTS.md
@@ -193,6 +193,12 @@ final class TypeDistributionBin {
193 193
 // Deletions are recorded by sampleUUIDHash because HKDeletedObject exposes UUIDs,
194 194
 // not complete sample payloads.
195 195
 
196
+// Interface updated 2026-05-23 — see AGENTS.md
197
+// HealthArchiveStore exposes SQL-first observation diff APIs:
198
+// diffSummary(_:) returns appeared/disappeared/representationChanged counts and
199
+// diffRecords(_:) returns a paged record list for one change kind. UI/cache agents
200
+// should consume these APIs instead of loading full observation record sets.
201
+
196 202
 // Storage objective updated 2026-05-23 — see AGENTS.md
197 203
 // Recurring complete snapshots are out of scope for the target architecture.
198 204
 // Store differential observations, versioned sample payloads, observation ranges,
+3 -3
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -25,7 +25,7 @@ There are no real deployments, only test installations. Existing prototype datab
25 25
 |------|----------------|--------------------|
26 26
 | Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index |
27 27
 | HealthKit capture | Prototype exists | Adapt capture to write differential SQLite observations first |
28
-| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, and initial XCTest coverage are in place; legacy write mirror still exists | Add SQL diff/count queries, large synthetic-data tests, then retire `archive_samples` |
28
+| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, initial SQL diff/count APIs, and XCTest coverage are in place; legacy write mirror still exists | Add aggregate/provenance SQL analysis, large synthetic-data tests, then retire `archive_samples` |
29 29
 | Core Data cache | Not implemented | Add rebuildable cache for expensive counts, summaries, report metadata, UI state |
30 30
 | SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition |
31 31
 | UI | Prototype exists | Reframe screens around observations, diffs, export, archive status |
@@ -38,7 +38,7 @@ 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. Move large diffs/counts into SQL queries with indexes/temp tables/paged results.
41
+1. Add aggregate/provenance/consolidation evidence queries on top of the SQLite archive.
42 42
 2. Expand the synthetic large-data test harness for diff/export memory behavior.
43 43
 3. Add Core Data UI/report cache and rebuild pipeline.
44 44
 4. Replace SwiftData UI dependencies with Core Data/cache DTOs.
@@ -54,7 +54,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
54 54
 - Current archive schema is not sufficient as the long-term source of truth.
55 55
 - Existing implementation may decode or cache too much data for low-end devices.
56 56
 - Old prototype database compatibility is no longer required.
57
-- Initial SQLite archive tests cover open/init/reset/idempotency, but not yet large-volume diff/export behavior.
57
+- Initial SQLite archive tests cover open/init/reset/idempotency and small observation diffs, but not yet large-volume diff/export behavior.
58 58
 
59 59
 ## Verification Checklist
60 60
 
+5 -5
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -145,10 +145,10 @@ Acceptance:
145 145
 Checklist:
146 146
 - [x] Implement point-in-time visible-record query.
147 147
 - [x] Implement paged record table query.
148
-- [ ] Implement appeared query between observations.
149
-- [ ] Implement disappeared query between observations.
150
-- [ ] Implement representationChanged query between observations.
151
-- [ ] Implement diff counts using temp tables or equivalent SQL-first strategy.
148
+- [x] Implement appeared query between observations.
149
+- [x] Implement disappeared query between observations.
150
+- [x] Implement representationChanged query between observations.
151
+- [x] Implement diff counts using temp tables or equivalent SQL-first strategy.
152 152
 - [ ] Implement aggregate comparison query.
153 153
 - [ ] Implement consolidation-likely evidence query.
154 154
 - [ ] Implement source/provenance breakdown query.
@@ -157,7 +157,7 @@ Checklist:
157 157
 Acceptance:
158 158
 - [x] Observation T can be reconstructed from ranges/events.
159 159
 - [ ] Large diff returns counts and first page without loading all rows.
160
-- [ ] Query results are deterministic and ordered.
160
+- [x] Query results are deterministic and ordered.
161 161
 - [ ] Consolidation evidence includes count, aggregate, coverage, density, and uncertainty data.
162 162
 
163 163
 ## Milestone 6 - Core Data UI/Report Cache
+59 -1
HealthProbe/Services/Protocols/HealthArchiveStore.swift
@@ -1,12 +1,14 @@
1 1
 import Foundation
2 2
 import HealthKit
3 3
 
4
-// Interface updated 2026-05-18 — see AGENTS.md
4
+// Interface updated 2026-05-23 — see AGENTS.md
5 5
 protocol HealthArchiveStore {
6 6
     func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary
7 7
     func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws
8 8
     func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws
9 9
     func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord]
10
+    func diffSummary(_ request: HealthArchiveDiffRequest) async throws -> HealthArchiveDiffSummary
11
+    func diffRecords(_ request: HealthArchiveDiffRecordRequest) async throws -> [ArchivedHealthRecord]
10 12
     func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL
11 13
     func checkIntegrity() async throws -> HealthArchiveIntegrityReport
12 14
 }
@@ -68,6 +70,62 @@ struct RecordCursor: Equatable, Sendable {
68 70
     let strictFingerprint: String
69 71
 }
70 72
 
73
+enum HealthArchiveDiffKind: String, Codable, Sendable {
74
+    case appeared
75
+    case disappeared
76
+    case representationChanged
77
+}
78
+
79
+struct HealthArchiveDiffRequest: Equatable, Sendable {
80
+    let fromObservationID: Int64
81
+    let toObservationID: Int64
82
+    let sampleTypeIdentifier: String?
83
+
84
+    init(fromObservationID: Int64, toObservationID: Int64, sampleTypeIdentifier: String? = nil) {
85
+        self.fromObservationID = fromObservationID
86
+        self.toObservationID = toObservationID
87
+        self.sampleTypeIdentifier = sampleTypeIdentifier
88
+    }
89
+}
90
+
91
+struct HealthArchiveDiffSummary: Equatable, Sendable {
92
+    let fromObservationID: Int64
93
+    let toObservationID: Int64
94
+    let sampleTypeIdentifier: String?
95
+    let appearedCount: Int
96
+    let disappearedCount: Int
97
+    let representationChangedCount: Int
98
+
99
+    var totalChangeCount: Int {
100
+        appearedCount + disappearedCount + representationChangedCount
101
+    }
102
+}
103
+
104
+struct HealthArchiveDiffRecordRequest: Equatable, Sendable {
105
+    let fromObservationID: Int64
106
+    let toObservationID: Int64
107
+    let sampleTypeIdentifier: String?
108
+    let kind: HealthArchiveDiffKind
109
+    let afterCursor: RecordCursor?
110
+    let limit: Int?
111
+
112
+    init(
113
+        fromObservationID: Int64,
114
+        toObservationID: Int64,
115
+        sampleTypeIdentifier: String? = nil,
116
+        kind: HealthArchiveDiffKind,
117
+        afterCursor: RecordCursor? = nil,
118
+        limit: Int? = nil
119
+    ) {
120
+        self.fromObservationID = fromObservationID
121
+        self.toObservationID = toObservationID
122
+        self.sampleTypeIdentifier = sampleTypeIdentifier
123
+        self.kind = kind
124
+        self.afterCursor = afterCursor
125
+        self.limit = limit
126
+    }
127
+}
128
+
71 129
 struct HealthArchiveReportRequest: Equatable, Sendable {
72 130
     let reportID: UUID
73 131
     let title: String
+231 -22
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -264,28 +264,196 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
264 264
 
265 265
             var records: [ArchivedHealthRecord] = []
266 266
             while sqlite3_step(statement) == SQLITE_ROW {
267
-                records.append(ArchivedHealthRecord(
268
-                    id: columnText(statement, 0) ?? "",
269
-                    sampleTypeIdentifier: columnText(statement, 1) ?? "",
270
-                    strictFingerprint: columnText(statement, 2) ?? "",
271
-                    semanticFingerprint: columnText(statement, 3),
272
-                    healthKitUUIDHash: columnText(statement, 4),
273
-                    startDate: columnUnixDate(statement, 5) ?? Date(timeIntervalSince1970: 0),
274
-                    endDate: columnUnixDate(statement, 6) ?? Date(timeIntervalSince1970: 0),
275
-                    firstSeenAt: columnUnixDate(statement, 7) ?? Date(timeIntervalSince1970: 0),
276
-                    lastSeenAt: columnUnixDate(statement, 8),
277
-                    lastVerifiedAt: columnUnixDate(statement, 9),
278
-                    disappearedAt: columnUnixDate(statement, 10),
279
-                    valueKind: columnText(statement, 11),
280
-                    value: columnDouble(statement, 12),
281
-                    unit: columnText(statement, 13),
282
-                    categoryValue: columnInt(statement, 14),
283
-                    workoutActivityType: columnInt(statement, 15),
284
-                    durationSeconds: columnDouble(statement, 16),
285
-                    sourceName: nil,
286
-                    sourceBundleIdentifier: columnText(statement, 17),
287
-                    deviceName: nil
288
-                ))
267
+                records.append(archiveRecord(from: statement))
268
+            }
269
+            return records
270
+        }
271
+    }
272
+
273
+    func diffSummary(_ request: HealthArchiveDiffRequest) async throws -> HealthArchiveDiffSummary {
274
+        let db = try openDatabase()
275
+        defer { sqlite3_close(db) }
276
+        try prepareSchemaIfNeeded(db)
277
+
278
+        let typeClause = request.sampleTypeIdentifier == nil ? "" : "AND t.type_identifier = ?"
279
+        let sql = """
280
+        WITH from_visible AS (
281
+            SELECT sample_id, version_id
282
+            FROM sample_visibility_ranges
283
+            WHERE first_observation_id <= ?
284
+              AND (last_observation_id IS NULL OR last_observation_id > ?)
285
+        ),
286
+        to_visible AS (
287
+            SELECT sample_id, version_id
288
+            FROM sample_visibility_ranges
289
+            WHERE first_observation_id <= ?
290
+              AND (last_observation_id IS NULL OR last_observation_id > ?)
291
+        )
292
+        SELECT
293
+            (
294
+                SELECT COUNT(*)
295
+                FROM to_visible tv
296
+                LEFT JOIN from_visible fv ON fv.sample_id = tv.sample_id
297
+                JOIN samples s ON s.id = tv.sample_id
298
+                JOIN sample_types t ON t.id = s.sample_type_id
299
+                WHERE fv.sample_id IS NULL \(typeClause)
300
+            ) AS appeared_count,
301
+            (
302
+                SELECT COUNT(*)
303
+                FROM from_visible fv
304
+                LEFT JOIN to_visible tv ON tv.sample_id = fv.sample_id
305
+                JOIN samples s ON s.id = fv.sample_id
306
+                JOIN sample_types t ON t.id = s.sample_type_id
307
+                WHERE tv.sample_id IS NULL \(typeClause)
308
+            ) AS disappeared_count,
309
+            (
310
+                SELECT COUNT(*)
311
+                FROM to_visible tv
312
+                JOIN from_visible fv ON fv.sample_id = tv.sample_id
313
+                JOIN samples s ON s.id = tv.sample_id
314
+                JOIN sample_types t ON t.id = s.sample_type_id
315
+                WHERE tv.version_id != fv.version_id \(typeClause)
316
+            ) AS representation_changed_count
317
+        """
318
+
319
+        return try withStatement(sql, db: db) { statement in
320
+            var index: Int32 = 1
321
+            bindDiffObservationIDs(request.fromObservationID, request.toObservationID, to: statement, startingAt: &index)
322
+            for _ in 0..<3 {
323
+                if let sampleTypeIdentifier = request.sampleTypeIdentifier {
324
+                    bindText(sampleTypeIdentifier, to: index, in: statement)
325
+                    index += 1
326
+                }
327
+            }
328
+
329
+            guard sqlite3_step(statement) == SQLITE_ROW else {
330
+                return HealthArchiveDiffSummary(
331
+                    fromObservationID: request.fromObservationID,
332
+                    toObservationID: request.toObservationID,
333
+                    sampleTypeIdentifier: request.sampleTypeIdentifier,
334
+                    appearedCount: 0,
335
+                    disappearedCount: 0,
336
+                    representationChangedCount: 0
337
+                )
338
+            }
339
+            return HealthArchiveDiffSummary(
340
+                fromObservationID: request.fromObservationID,
341
+                toObservationID: request.toObservationID,
342
+                sampleTypeIdentifier: request.sampleTypeIdentifier,
343
+                appearedCount: columnInt(statement, 0) ?? 0,
344
+                disappearedCount: columnInt(statement, 1) ?? 0,
345
+                representationChangedCount: columnInt(statement, 2) ?? 0
346
+            )
347
+        }
348
+    }
349
+
350
+    func diffRecords(_ request: HealthArchiveDiffRecordRequest) async throws -> [ArchivedHealthRecord] {
351
+        let db = try openDatabase()
352
+        defer { sqlite3_close(db) }
353
+        try prepareSchemaIfNeeded(db)
354
+
355
+        let selectedRangeSQL: String
356
+        switch request.kind {
357
+        case .appeared:
358
+            selectedRangeSQL = """
359
+            SELECT tv.sample_id, tv.version_id
360
+            FROM to_visible tv
361
+            LEFT JOIN from_visible fv ON fv.sample_id = tv.sample_id
362
+            WHERE fv.sample_id IS NULL
363
+            """
364
+        case .disappeared:
365
+            selectedRangeSQL = """
366
+            SELECT fv.sample_id, fv.version_id
367
+            FROM from_visible fv
368
+            LEFT JOIN to_visible tv ON tv.sample_id = fv.sample_id
369
+            WHERE tv.sample_id IS NULL
370
+            """
371
+        case .representationChanged:
372
+            selectedRangeSQL = """
373
+            SELECT tv.sample_id, tv.version_id
374
+            FROM to_visible tv
375
+            JOIN from_visible fv ON fv.sample_id = tv.sample_id
376
+            WHERE tv.version_id != fv.version_id
377
+            """
378
+        }
379
+
380
+        var clauses: [String] = []
381
+        if request.sampleTypeIdentifier != nil {
382
+            clauses.append("t.type_identifier = ?")
383
+        }
384
+        if request.afterCursor != nil {
385
+            clauses.append("(v.start_date > ? OR (v.start_date = ? AND s.strict_fingerprint > ?))")
386
+        }
387
+        let whereClause = clauses.isEmpty ? "" : "WHERE \(clauses.joined(separator: " AND "))"
388
+        let limitClause = request.limit.map { "LIMIT \(max($0, 0))" } ?? ""
389
+        let sql = """
390
+        WITH from_visible AS (
391
+            SELECT sample_id, version_id
392
+            FROM sample_visibility_ranges
393
+            WHERE first_observation_id <= ?
394
+              AND (last_observation_id IS NULL OR last_observation_id > ?)
395
+        ),
396
+        to_visible AS (
397
+            SELECT sample_id, version_id
398
+            FROM sample_visibility_ranges
399
+            WHERE first_observation_id <= ?
400
+              AND (last_observation_id IS NULL OR last_observation_id > ?)
401
+        ),
402
+        selected_ranges AS (
403
+            \(selectedRangeSQL)
404
+        ),
405
+        event_summary AS (
406
+            SELECT
407
+                sample_id,
408
+                MAX(CASE WHEN event_kind != 'disappeared' THEN observed_at END) AS last_seen_at,
409
+                MAX(observed_at) AS last_verified_at,
410
+                MAX(CASE WHEN event_kind = 'disappeared' THEN observed_at END) AS disappeared_at
411
+            FROM sample_observation_events
412
+            WHERE observation_id <= ?
413
+            GROUP BY sample_id
414
+        )
415
+        SELECT
416
+               COALESCE(s.sample_uuid_hash, s.strict_fingerprint) AS record_id,
417
+               t.type_identifier, s.strict_fingerprint, s.semantic_fingerprint, s.sample_uuid_hash,
418
+               v.start_date, v.end_date, s.first_seen_at,
419
+               COALESCE(es.last_seen_at, s.first_seen_at) AS last_seen_at,
420
+               es.last_verified_at,
421
+               es.disappeared_at,
422
+               v.value_kind, v.numeric_value, v.unit, v.category_value, v.workout_activity_type, v.duration_seconds,
423
+               src.bundle_identifier
424
+        FROM selected_ranges srng
425
+        JOIN samples s ON s.id = srng.sample_id
426
+        JOIN sample_types t ON t.id = s.sample_type_id
427
+        JOIN sample_versions v ON v.id = srng.version_id
428
+        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
429
+        LEFT JOIN sources src ON src.id = sr.source_id
430
+        LEFT JOIN event_summary es ON es.sample_id = s.id
431
+        \(whereClause)
432
+        ORDER BY v.start_date ASC, s.strict_fingerprint ASC
433
+        \(limitClause)
434
+        """
435
+
436
+        return try withStatement(sql, db: db) { statement in
437
+            var index: Int32 = 1
438
+            bindDiffObservationIDs(request.fromObservationID, request.toObservationID, to: statement, startingAt: &index)
439
+            bindInt64(request.toObservationID, to: index, in: statement)
440
+            index += 1
441
+            if let sampleTypeIdentifier = request.sampleTypeIdentifier {
442
+                bindText(sampleTypeIdentifier, to: index, in: statement)
443
+                index += 1
444
+            }
445
+            if let cursor = request.afterCursor {
446
+                sqlite3_bind_double(statement, index, cursor.startDate.timeIntervalSince1970)
447
+                index += 1
448
+                sqlite3_bind_double(statement, index, cursor.startDate.timeIntervalSince1970)
449
+                index += 1
450
+                bindText(cursor.strictFingerprint, to: index, in: statement)
451
+                index += 1
452
+            }
453
+
454
+            var records: [ArchivedHealthRecord] = []
455
+            while sqlite3_step(statement) == SQLITE_ROW {
456
+                records.append(archiveRecord(from: statement))
289 457
             }
290 458
             return records
291 459
         }
@@ -1570,6 +1738,47 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
1570 1738
         }
1571 1739
     }
1572 1740
 
1741
+    private func bindDiffObservationIDs(
1742
+        _ fromObservationID: Int64,
1743
+        _ toObservationID: Int64,
1744
+        to statement: OpaquePointer?,
1745
+        startingAt index: inout Int32
1746
+    ) {
1747
+        bindInt64(fromObservationID, to: index, in: statement)
1748
+        index += 1
1749
+        bindInt64(fromObservationID, to: index, in: statement)
1750
+        index += 1
1751
+        bindInt64(toObservationID, to: index, in: statement)
1752
+        index += 1
1753
+        bindInt64(toObservationID, to: index, in: statement)
1754
+        index += 1
1755
+    }
1756
+
1757
+    private func archiveRecord(from statement: OpaquePointer?) -> ArchivedHealthRecord {
1758
+        ArchivedHealthRecord(
1759
+            id: columnText(statement, 0) ?? "",
1760
+            sampleTypeIdentifier: columnText(statement, 1) ?? "",
1761
+            strictFingerprint: columnText(statement, 2) ?? "",
1762
+            semanticFingerprint: columnText(statement, 3),
1763
+            healthKitUUIDHash: columnText(statement, 4),
1764
+            startDate: columnUnixDate(statement, 5) ?? Date(timeIntervalSince1970: 0),
1765
+            endDate: columnUnixDate(statement, 6) ?? Date(timeIntervalSince1970: 0),
1766
+            firstSeenAt: columnUnixDate(statement, 7) ?? Date(timeIntervalSince1970: 0),
1767
+            lastSeenAt: columnUnixDate(statement, 8),
1768
+            lastVerifiedAt: columnUnixDate(statement, 9),
1769
+            disappearedAt: columnUnixDate(statement, 10),
1770
+            valueKind: columnText(statement, 11),
1771
+            value: columnDouble(statement, 12),
1772
+            unit: columnText(statement, 13),
1773
+            categoryValue: columnInt(statement, 14),
1774
+            workoutActivityType: columnInt(statement, 15),
1775
+            durationSeconds: columnDouble(statement, 16),
1776
+            sourceName: nil,
1777
+            sourceBundleIdentifier: columnText(statement, 17),
1778
+            deviceName: nil
1779
+        )
1780
+    }
1781
+
1573 1782
     private func requiredInt64(
1574 1783
         _ sql: String,
1575 1784
         db: OpaquePointer?,
+85 -4
HealthProbeTests/SQLiteHealthArchiveStoreTests.swift
@@ -77,6 +77,59 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
77 77
         XCTAssertTrue(report.passed)
78 78
     }
79 79
 
80
+    func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
81
+        let url = databaseURL()
82
+        let store = SQLiteHealthArchiveStore(databaseURL: url)
83
+        let firstSample = makeStepCountSample(value: 42, start: 1_000)
84
+        let secondSample = makeStepCountSample(value: 7, start: 2_000)
85
+        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
86
+
87
+        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
88
+        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
89
+        try await store.recordDisappearance(
90
+            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
91
+            sampleTypeIdentifier: typeIdentifier,
92
+            observedMissingAt: Date(timeIntervalSince1970: 3_120)
93
+        )
94
+        let observationIDs = try observationIDs(at: url)
95
+        XCTAssertEqual(observationIDs.count, 3)
96
+
97
+        let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
98
+            fromObservationID: observationIDs[0],
99
+            toObservationID: observationIDs[1],
100
+            sampleTypeIdentifier: typeIdentifier
101
+        ))
102
+        let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
103
+            fromObservationID: observationIDs[0],
104
+            toObservationID: observationIDs[1],
105
+            sampleTypeIdentifier: typeIdentifier,
106
+            kind: .appeared,
107
+            limit: 10
108
+        ))
109
+        let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
110
+            fromObservationID: observationIDs[1],
111
+            toObservationID: observationIDs[2],
112
+            sampleTypeIdentifier: typeIdentifier
113
+        ))
114
+        let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
115
+            fromObservationID: observationIDs[1],
116
+            toObservationID: observationIDs[2],
117
+            sampleTypeIdentifier: typeIdentifier,
118
+            kind: .disappeared,
119
+            limit: 10
120
+        ))
121
+
122
+        XCTAssertEqual(appearedSummary.appearedCount, 1)
123
+        XCTAssertEqual(appearedSummary.disappearedCount, 0)
124
+        XCTAssertEqual(appearedSummary.representationChangedCount, 0)
125
+        XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"])
126
+        XCTAssertEqual(disappearedSummary.appearedCount, 0)
127
+        XCTAssertEqual(disappearedSummary.disappearedCount, 1)
128
+        XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
129
+        XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
130
+        XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
131
+    }
132
+
80 133
     private func databaseURL() -> URL {
81 134
         temporaryDirectory.appending(path: "Archive.sqlite")
82 135
     }
@@ -94,11 +147,15 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
94 147
     }
95 148
 
96 149
     private func makeStepCountSample() -> HKQuantitySample {
150
+        makeStepCountSample(value: 42, start: 1_000)
151
+    }
152
+
153
+    private func makeStepCountSample(value: Double, start: TimeInterval) -> HKQuantitySample {
97 154
         let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
98
-        let quantity = HKQuantity(unit: .count(), doubleValue: 42)
99
-        let start = Date(timeIntervalSince1970: 1_000)
100
-        let end = Date(timeIntervalSince1970: 1_300)
101
-        return HKQuantitySample(type: quantityType, quantity: quantity, start: start, end: end)
155
+        let quantity = HKQuantity(unit: .count(), doubleValue: value)
156
+        let startDate = Date(timeIntervalSince1970: start)
157
+        let endDate = Date(timeIntervalSince1970: start + 300)
158
+        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
102 159
     }
103 160
 
104 161
     private func countRows(in tableName: String, at url: URL) throws -> Int {
@@ -124,6 +181,30 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
124 181
         return Int(sqlite3_column_int(statement, 0))
125 182
     }
126 183
 
184
+    private func observationIDs(at url: URL) throws -> [Int64] {
185
+        var db: OpaquePointer?
186
+        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
187
+            sqlite3_close(db)
188
+            XCTFail("Could not open test database")
189
+            return []
190
+        }
191
+        defer { sqlite3_close(db) }
192
+
193
+        var statement: OpaquePointer?
194
+        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
195
+            sqlite3_finalize(statement)
196
+            XCTFail("Could not prepare observation query")
197
+            return []
198
+        }
199
+        defer { sqlite3_finalize(statement) }
200
+
201
+        var ids: [Int64] = []
202
+        while sqlite3_step(statement) == SQLITE_ROW {
203
+            ids.append(sqlite3_column_int64(statement, 0))
204
+        }
205
+        return ids
206
+    }
207
+
127 208
     private func sampleVersionDebugRows(at url: URL) throws -> String {
128 209
         var db: OpaquePointer?
129 210
         guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {