Showing 4 changed files with 215 additions and 3 deletions
+2 -2
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 and differential write path partially implemented; legacy read table still active | Add daily aggregates, SQL reads, integrity tests, then retire `archive_samples` |
28
+| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, and integrity report are partially implemented; legacy read table still active | Add SQL reads, open/schema-version tests, idempotency 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. Finish differential write path: daily aggregates and stricter retry/idempotency tests.
41
+1. Finish differential write path hardening with stricter retry/idempotency tests.
42 42
 2. Add SQLite integrity/open/schema-version tests.
43 43
 3. Move archive reads from `archive_samples` to SQL over visibility ranges and sample versions.
44 44
 4. Move large diffs/counts into SQL queries with indexes/temp tables/paged results.
+2 -1
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -102,6 +102,7 @@ Checklist:
102 102
 - [x] Implement `export_manifests`.
103 103
 - [x] Implement `export_items`.
104 104
 - [x] Add required indexes.
105
+- [x] Add archive integrity report for schema version, required tables, `PRAGMA integrity_check`, and foreign keys.
105 106
 - [ ] Add SQLite integrity/open/schema-version tests.
106 107
 
107 108
 Acceptance:
@@ -126,7 +127,7 @@ Checklist:
126 127
 - [x] Record `HKDeletedObject` evidence by UUID hash.
127 128
 - [x] Close visibility ranges for disappeared/deleted samples.
128 129
 - [x] Maintain open visibility ranges for visible samples.
129
-- [ ] Rebuild/update affected aggregates after capture.
130
+- [x] Rebuild/update affected type summaries and daily aggregates after capture/delete observations.
130 131
 - [x] Commit SQLite before Core Data/cache work.
131 132
 - [ ] Make repeated capture page writes idempotent.
132 133
 
+16 -0
HealthProbe/Services/Protocols/HealthArchiveStore.swift
@@ -8,6 +8,7 @@ protocol HealthArchiveStore {
8 8
     func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws
9 9
     func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord]
10 10
     func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL
11
+    func checkIntegrity() async throws -> HealthArchiveIntegrityReport
11 12
 }
12 13
 
13 14
 struct HealthArchiveWriteSummary: Equatable, Sendable {
@@ -16,6 +17,21 @@ struct HealthArchiveWriteSummary: Equatable, Sendable {
16 17
     let unchangedCount: Int
17 18
 }
18 19
 
20
+struct HealthArchiveIntegrityReport: Equatable, Sendable {
21
+    let schemaVersion: Int?
22
+    let sqliteIntegrityStatus: String
23
+    let foreignKeyIssueCount: Int
24
+    let requiredTableNames: Set<String>
25
+    let missingTableNames: Set<String>
26
+
27
+    var passed: Bool {
28
+        schemaVersion == 2 &&
29
+        sqliteIntegrityStatus == "ok" &&
30
+        foreignKeyIssueCount == 0 &&
31
+        missingTableNames.isEmpty
32
+    }
33
+}
34
+
19 35
 struct HealthArchiveRecordRequest: Equatable, Sendable {
20 36
     let sampleTypeIdentifier: String?
21 37
     let fingerprints: Set<String>
+195 -0
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -14,6 +14,27 @@ private enum SQLiteHealthArchiveStoreError: Error {
14 14
 actor SQLiteHealthArchiveStore: HealthArchiveStore {
15 15
     static let shared = SQLiteHealthArchiveStore()
16 16
     nonisolated private static let archiveSchemaVersion = 2
17
+    nonisolated private static let requiredArchiveV2Tables: [String] = [
18
+        "schema_migrations",
19
+        "archive_metadata",
20
+        "device_chains",
21
+        "observations",
22
+        "sample_types",
23
+        "observation_type_runs",
24
+        "sources",
25
+        "source_revisions",
26
+        "hk_devices",
27
+        "metadata_blobs",
28
+        "samples",
29
+        "sample_versions",
30
+        "sample_observation_events",
31
+        "sample_visibility_ranges",
32
+        "sample_relationships",
33
+        "observation_type_summaries",
34
+        "daily_type_aggregates",
35
+        "export_manifests",
36
+        "export_items"
37
+    ]
17 38
 
18 39
     private let databaseURL: URL
19 40
     private var didPrepareSchema = false
@@ -94,6 +115,12 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
94 115
                     db: db
95 116
                 )
96 117
                 try rebuildTypeSummary(observationID: observationID, sampleTypeID: sampleTypeID, db: db)
118
+                try rebuildDailyAggregates(
119
+                    observationID: observationID,
120
+                    sampleTypeID: sampleTypeID,
121
+                    observedAt: observedMissingAt,
122
+                    db: db
123
+                )
97 124
             }
98 125
 
99 126
             let sql = """
@@ -234,6 +261,31 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
234 261
         return exportURL
235 262
     }
236 263
 
264
+    func checkIntegrity() async throws -> HealthArchiveIntegrityReport {
265
+        let db = try openDatabase()
266
+        defer { sqlite3_close(db) }
267
+        try prepareSchemaIfNeeded(db)
268
+
269
+        let schemaVersion = try archiveSchemaVersionIfPresent(db)
270
+        let sqliteIntegrityStatus = try firstText("PRAGMA integrity_check", db: db) ?? "missing"
271
+        let foreignKeyIssueCount = try countRows("PRAGMA foreign_key_check", db: db)
272
+        let requiredTables = Set(Self.requiredArchiveV2Tables)
273
+        var missingTables = Set<String>()
274
+        for tableName in requiredTables {
275
+            if try !tableExists(tableName, db: db) {
276
+                missingTables.insert(tableName)
277
+            }
278
+        }
279
+
280
+        return HealthArchiveIntegrityReport(
281
+            schemaVersion: schemaVersion,
282
+            sqliteIntegrityStatus: sqliteIntegrityStatus,
283
+            foreignKeyIssueCount: foreignKeyIssueCount,
284
+            requiredTableNames: requiredTables,
285
+            missingTableNames: missingTables
286
+        )
287
+    }
288
+
237 289
     private func openDatabase() throws -> OpaquePointer? {
238 290
         try FileManager.default.createDirectory(
239 291
             at: databaseURL.deletingLastPathComponent(),
@@ -697,6 +749,12 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
697 749
 
698 750
         for sampleTypeID in touchedTypeIDs {
699 751
             try rebuildTypeSummary(observationID: observationID, sampleTypeID: sampleTypeID, db: db)
752
+            try rebuildDailyAggregates(
753
+                observationID: observationID,
754
+                sampleTypeID: sampleTypeID,
755
+                observedAt: observedAt,
756
+                db: db
757
+            )
700 758
         }
701 759
 
702 760
         return HealthArchiveWriteSummary(
@@ -1263,6 +1321,99 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
1263 1321
         }
1264 1322
     }
1265 1323
 
1324
+    private func rebuildDailyAggregates(
1325
+        observationID: Int64,
1326
+        sampleTypeID: Int64,
1327
+        observedAt: Date,
1328
+        db: OpaquePointer?
1329
+    ) throws {
1330
+        let secondsFromGMT = TimeZone.current.secondsFromGMT(for: observedAt)
1331
+        try withStatement(
1332
+            "DELETE FROM daily_type_aggregates WHERE observation_id = ? AND sample_type_id = ?",
1333
+            db: db
1334
+        ) { statement in
1335
+            bindInt64(observationID, to: 1, in: statement)
1336
+            bindInt64(sampleTypeID, to: 2, in: statement)
1337
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1338
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1339
+            }
1340
+        }
1341
+
1342
+        let rows = try dailyAggregateRows(sampleTypeID: sampleTypeID, secondsFromGMT: secondsFromGMT, db: db)
1343
+        try withStatement(
1344
+            """
1345
+            INSERT INTO daily_type_aggregates (
1346
+                observation_id, sample_type_id, bucket_start, bucket_end,
1347
+                visible_record_count, value_sum, value_max, source_revision_id, aggregate_hash
1348
+            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1349
+            """,
1350
+            db: db
1351
+        ) { statement in
1352
+            for row in rows {
1353
+                sqlite3_reset(statement)
1354
+                sqlite3_clear_bindings(statement)
1355
+                let aggregateHash = HashService.archiveContentHash(
1356
+                    domain: "hp:v2:daily_type_aggregate",
1357
+                    parts: row.hashParts(observationID: observationID, sampleTypeID: sampleTypeID)
1358
+                )
1359
+                bindInt64(observationID, to: 1, in: statement)
1360
+                bindInt64(sampleTypeID, to: 2, in: statement)
1361
+                bindDouble(row.bucketStart, to: 3, in: statement)
1362
+                bindDouble(row.bucketEnd, to: 4, in: statement)
1363
+                bindInt(row.visibleRecordCount, to: 5, in: statement)
1364
+                bindDouble(row.valueSum, to: 6, in: statement)
1365
+                bindDouble(row.valueMax, to: 7, in: statement)
1366
+                bindInt64(row.sourceRevisionID, to: 8, in: statement)
1367
+                bindText(aggregateHash, to: 9, in: statement)
1368
+                guard sqlite3_step(statement) == SQLITE_DONE else {
1369
+                    throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1370
+                }
1371
+            }
1372
+        }
1373
+    }
1374
+
1375
+    private func dailyAggregateRows(
1376
+        sampleTypeID: Int64,
1377
+        secondsFromGMT: Int,
1378
+        db: OpaquePointer?
1379
+    ) throws -> [ArchiveV2DailyAggregateRow] {
1380
+        let sql = """
1381
+        SELECT
1382
+            CAST(((v.start_date + ?) / 86400) AS INTEGER) * 86400 - ? AS bucket_start,
1383
+            CAST(((v.start_date + ?) / 86400) AS INTEGER) * 86400 - ? + 86400 AS bucket_end,
1384
+            COUNT(*),
1385
+            SUM(v.numeric_value),
1386
+            MAX(v.numeric_value),
1387
+            v.source_revision_id
1388
+        FROM sample_visibility_ranges r
1389
+        JOIN samples s ON s.id = r.sample_id
1390
+        JOIN sample_versions v ON v.id = r.version_id
1391
+        WHERE s.sample_type_id = ? AND r.last_observation_id IS NULL
1392
+        GROUP BY bucket_start, bucket_end, v.source_revision_id
1393
+        ORDER BY bucket_start ASC, v.source_revision_id ASC
1394
+        """
1395
+        return try withStatement(sql, db: db) { statement in
1396
+            sqlite3_bind_double(statement, 1, Double(secondsFromGMT))
1397
+            sqlite3_bind_double(statement, 2, Double(secondsFromGMT))
1398
+            sqlite3_bind_double(statement, 3, Double(secondsFromGMT))
1399
+            sqlite3_bind_double(statement, 4, Double(secondsFromGMT))
1400
+            bindInt64(sampleTypeID, to: 5, in: statement)
1401
+
1402
+            var rows: [ArchiveV2DailyAggregateRow] = []
1403
+            while sqlite3_step(statement) == SQLITE_ROW {
1404
+                rows.append(ArchiveV2DailyAggregateRow(
1405
+                    bucketStart: sqlite3_column_double(statement, 0),
1406
+                    bucketEnd: sqlite3_column_double(statement, 1),
1407
+                    visibleRecordCount: columnInt(statement, 2) ?? 0,
1408
+                    valueSum: columnDouble(statement, 3),
1409
+                    valueMax: columnDouble(statement, 4),
1410
+                    sourceRevisionID: columnInt64(statement, 5)
1411
+                ))
1412
+            }
1413
+            return rows
1414
+        }
1415
+    }
1416
+
1266 1417
     private func typeSummary(observationID: Int64, sampleTypeID: Int64, db: OpaquePointer?) throws -> ArchiveV2TypeSummary {
1267 1418
         let counts = try eventCounts(observationID: observationID, sampleTypeID: sampleTypeID, db: db)
1268 1419
         let aggregate = try visibleAggregate(sampleTypeID: sampleTypeID, db: db)
@@ -1366,6 +1517,23 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
1366 1517
         }
1367 1518
     }
1368 1519
 
1520
+    private func firstText(_ sql: String, db: OpaquePointer?) throws -> String? {
1521
+        try withStatement(sql, db: db) { statement in
1522
+            guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
1523
+            return columnText(statement, 0)
1524
+        }
1525
+    }
1526
+
1527
+    private func countRows(_ sql: String, db: OpaquePointer?) throws -> Int {
1528
+        try withStatement(sql, db: db) { statement in
1529
+            var count = 0
1530
+            while sqlite3_step(statement) == SQLITE_ROW {
1531
+                count += 1
1532
+            }
1533
+            return count
1534
+        }
1535
+    }
1536
+
1369 1537
     private func execute(_ sql: String, db: OpaquePointer?) throws {
1370 1538
         guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else {
1371 1539
             throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
@@ -1642,6 +1810,28 @@ private struct ArchiveV2VisibleAggregate {
1642 1810
     let valueMax: Double?
1643 1811
 }
1644 1812
 
1813
+private struct ArchiveV2DailyAggregateRow {
1814
+    let bucketStart: Double
1815
+    let bucketEnd: Double
1816
+    let visibleRecordCount: Int
1817
+    let valueSum: Double?
1818
+    let valueMax: Double?
1819
+    let sourceRevisionID: Int64?
1820
+
1821
+    func hashParts(observationID: Int64, sampleTypeID: Int64) -> [String?] {
1822
+        [
1823
+            String(observationID),
1824
+            String(sampleTypeID),
1825
+            String(bucketStart),
1826
+            String(bucketEnd),
1827
+            String(visibleRecordCount),
1828
+            valueSum.map { String(format: "%.17g", $0) },
1829
+            valueMax.map { String(format: "%.17g", $0) },
1830
+            sourceRevisionID.map(String.init)
1831
+        ]
1832
+    }
1833
+}
1834
+
1645 1835
 nonisolated private struct HealthArchiveReportPayload: Encodable {
1646 1836
     let reportID: UUID
1647 1837
     let title: String
@@ -1713,6 +1903,11 @@ nonisolated private func columnInt(_ statement: OpaquePointer?, _ index: Int32)
1713 1903
     return Int(sqlite3_column_int(statement, index))
1714 1904
 }
1715 1905
 
1906
+nonisolated private func columnInt64(_ statement: OpaquePointer?, _ index: Int32) -> Int64? {
1907
+    guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil }
1908
+    return sqlite3_column_int64(statement, index)
1909
+}
1910
+
1716 1911
 nonisolated private func quotedIdentifier(_ value: String) -> String {
1717 1912
     "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
1718 1913
 }