@@ -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. |
@@ -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 |
|
@@ -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> |
@@ -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 |
} |