@@ -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 |
|
@@ -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 |
@@ -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()
|
@@ -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, |
@@ -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 {
|