@@ -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 | Capture now opens one archive observation per user-visible snapshot, attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it, no longer aborts initial full-history imports after a fixed 30-minute wall-clock cap while page-level HealthKit timeouts remain in place, defers grouped observation summary/daily aggregate rebuilds until per-type verification instead of rebuilding after every imported page, and persists large HealthKit pages in smaller archive chunks while using type-specific import strategies: conservative paging for the heaviest metrics, more aggressive pages/chunks for ordinary metrics, adaptive write chunk sizing, batched deleted-object persistence, explicit task yields, and lower-allocation streaming loops to avoid long monolithic SQLite stalls | Continue moving UI/cache reads to archive-backed observation ids and revisit full checkpoint/resume and background collection separately | |
| 28 |
-| SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed, the hot write path now reuses prepared SQLite statements within grouped page writes instead of reparsing the same SQL for every sample, processes sample rows in a lower-allocation streaming loop, batches same-page deleted-object evidence in one transaction, adds composite indexes for visibility-range and sample-uuid hot lookups, and opens SQLite connections with import-friendly busy timeout / synchronous / temp-store pragmas | Continue moving capture/Dashboard actions to archive/cache DTOs | |
|
| 28 |
+| SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed, the hot write path now reuses prepared SQLite statements within grouped page writes instead of reparsing the same SQL for every sample, caches repeated sample-type/source/source-revision/device/metadata id lookups within grouped writes, processes sample rows in a lower-allocation streaming loop, batches same-page deleted-object evidence in one transaction, adds composite indexes for visibility-range and sample-uuid hot lookups, and opens SQLite connections with import-friendly busy timeout / synchronous / temp-store pragmas | Continue moving capture/Dashboard actions to archive/cache DTOs | |
|
| 29 | 29 |
| Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation | |
| 30 | 30 |
| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration, local device profile settings, operation logging, ContentView preview, Settings data maintenance, legacy detail/PDF views, unused legacy repair/observer services, Dashboard view/view-model access, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; stop returning/storing `HealthSnapshot` bridge handles before removing `ModelContainer` | |
| 31 | 31 |
| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, and Dashboard view/view-model code no longer imports SwiftData or reads `ModelContext`; capture/review actions now route through DTOs and snapshot ids, with the remaining legacy bridge isolated in `HealthKitService`. Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; unused legacy SwiftData snapshot/type detail and PDF views have been deleted; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language; Settings can now schedule a full test-database reset for the next app launch | Stop writing prototype `HealthSnapshot` bridge rows during capture/review | |
@@ -1851,6 +1851,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1851 | 1851 |
) throws -> HealthArchiveWriteSummary {
|
| 1852 | 1852 |
let statementCache = SQLiteStatementCache(db: db) |
| 1853 | 1853 |
defer { statementCache.finalizeAll() }
|
| 1854 |
+ let lookupCache = SQLiteWriteLookupCache() |
|
| 1854 | 1855 |
var inserted = 0 |
| 1855 | 1856 |
var updated = 0 |
| 1856 | 1857 |
var unchanged = 0 |
@@ -1863,7 +1864,8 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1863 | 1864 |
row, |
| 1864 | 1865 |
observationID: observationID, |
| 1865 | 1866 |
db: db, |
| 1866 |
- statementCache: statementCache |
|
| 1867 |
+ statementCache: statementCache, |
|
| 1868 |
+ lookupCache: lookupCache |
|
| 1867 | 1869 |
) |
| 1868 | 1870 |
} |
| 1869 | 1871 |
var counts = typeEventCounts[result.sampleTypeID, default: (inserted: 0, deleted: 0)] |
@@ -1918,10 +1920,12 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1918 | 1920 |
) throws {
|
| 1919 | 1921 |
let statementCache = SQLiteStatementCache(db: db) |
| 1920 | 1922 |
defer { statementCache.finalizeAll() }
|
| 1923 |
+ let lookupCache = SQLiteWriteLookupCache() |
|
| 1921 | 1924 |
let sampleTypeID = try upsertSampleType( |
| 1922 | 1925 |
typeIdentifier: sampleType.identifier, |
| 1923 | 1926 |
db: db, |
| 1924 |
- statementCache: statementCache |
|
| 1927 |
+ statementCache: statementCache, |
|
| 1928 |
+ lookupCache: lookupCache |
|
| 1925 | 1929 |
) |
| 1926 | 1930 |
let visibleCount = try visibleAggregate(sampleTypeID: sampleTypeID, db: db).visibleRecordCount |
| 1927 | 1931 |
try insertObservationTypeRun( |
@@ -1972,10 +1976,12 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1972 | 1976 |
) throws -> Int {
|
| 1973 | 1977 |
let statementCache = SQLiteStatementCache(db: db) |
| 1974 | 1978 |
defer { statementCache.finalizeAll() }
|
| 1979 |
+ let lookupCache = SQLiteWriteLookupCache() |
|
| 1975 | 1980 |
guard let sampleTypeID = try sampleTypeID( |
| 1976 | 1981 |
typeIdentifier: sampleTypeIdentifier, |
| 1977 | 1982 |
db: db, |
| 1978 |
- statementCache: statementCache |
|
| 1983 |
+ statementCache: statementCache, |
|
| 1984 |
+ lookupCache: lookupCache |
|
| 1979 | 1985 |
) else {
|
| 1980 | 1986 |
return 0 |
| 1981 | 1987 |
} |
@@ -2043,12 +2049,33 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2043 | 2049 |
_ row: ArchiveSampleRow, |
| 2044 | 2050 |
observationID: Int64, |
| 2045 | 2051 |
db: OpaquePointer?, |
| 2046 |
- statementCache: SQLiteStatementCache |
|
| 2052 |
+ statementCache: SQLiteStatementCache, |
|
| 2053 |
+ lookupCache: SQLiteWriteLookupCache |
|
| 2047 | 2054 |
) throws -> ArchiveV2SampleWriteResult {
|
| 2048 |
- let sampleTypeID = try upsertSampleType(typeIdentifier: row.typeIdentifier, db: db, statementCache: statementCache) |
|
| 2049 |
- let sourceRevisionID = try upsertSourceRevision(row, db: db, statementCache: statementCache) |
|
| 2050 |
- let deviceID = try upsertDevice(row, db: db, statementCache: statementCache) |
|
| 2051 |
- let metadataID = try upsertMetadataBlob(row, db: db, statementCache: statementCache) |
|
| 2055 |
+ let sampleTypeID = try upsertSampleType( |
|
| 2056 |
+ typeIdentifier: row.typeIdentifier, |
|
| 2057 |
+ db: db, |
|
| 2058 |
+ statementCache: statementCache, |
|
| 2059 |
+ lookupCache: lookupCache |
|
| 2060 |
+ ) |
|
| 2061 |
+ let sourceRevisionID = try upsertSourceRevision( |
|
| 2062 |
+ row, |
|
| 2063 |
+ db: db, |
|
| 2064 |
+ statementCache: statementCache, |
|
| 2065 |
+ lookupCache: lookupCache |
|
| 2066 |
+ ) |
|
| 2067 |
+ let deviceID = try upsertDevice( |
|
| 2068 |
+ row, |
|
| 2069 |
+ db: db, |
|
| 2070 |
+ statementCache: statementCache, |
|
| 2071 |
+ lookupCache: lookupCache |
|
| 2072 |
+ ) |
|
| 2073 |
+ let metadataID = try upsertMetadataBlob( |
|
| 2074 |
+ row, |
|
| 2075 |
+ db: db, |
|
| 2076 |
+ statementCache: statementCache, |
|
| 2077 |
+ lookupCache: lookupCache |
|
| 2078 |
+ ) |
|
| 2052 | 2079 |
let sampleResult = try upsertSample( |
| 2053 | 2080 |
row, |
| 2054 | 2081 |
sampleTypeID: sampleTypeID, |
@@ -2275,7 +2302,15 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2275 | 2302 |
} |
| 2276 | 2303 |
} |
| 2277 | 2304 |
|
| 2278 |
- private func upsertSampleType(typeIdentifier: String, db: OpaquePointer?, statementCache: SQLiteStatementCache? = nil) throws -> Int64 {
|
|
| 2305 |
+ private func upsertSampleType( |
|
| 2306 |
+ typeIdentifier: String, |
|
| 2307 |
+ db: OpaquePointer?, |
|
| 2308 |
+ statementCache: SQLiteStatementCache? = nil, |
|
| 2309 |
+ lookupCache: SQLiteWriteLookupCache? = nil |
|
| 2310 |
+ ) throws -> Int64 {
|
|
| 2311 |
+ if let cached = lookupCache?.sampleTypeIDs[typeIdentifier] {
|
|
| 2312 |
+ return cached |
|
| 2313 |
+ } |
|
| 2279 | 2314 |
try withStatement( |
| 2280 | 2315 |
"INSERT OR IGNORE INTO sample_types (type_identifier, display_name, category) VALUES (?, NULL, NULL)", |
| 2281 | 2316 |
db: db, |
@@ -2286,33 +2321,63 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2286 | 2321 |
throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
| 2287 | 2322 |
} |
| 2288 | 2323 |
} |
| 2289 |
- return try requiredInt64( |
|
| 2324 |
+ let id = try requiredInt64( |
|
| 2290 | 2325 |
"SELECT id FROM sample_types WHERE type_identifier = ? LIMIT 1", |
| 2291 | 2326 |
db: db, |
| 2292 | 2327 |
statementCache: statementCache |
| 2293 | 2328 |
) { statement in
|
| 2294 | 2329 |
bindText(typeIdentifier, to: 1, in: statement) |
| 2295 | 2330 |
} |
| 2331 |
+ lookupCache?.sampleTypeIDs[typeIdentifier] = id |
|
| 2332 |
+ return id |
|
| 2296 | 2333 |
} |
| 2297 | 2334 |
|
| 2298 |
- private func sampleTypeID(typeIdentifier: String, db: OpaquePointer?, statementCache: SQLiteStatementCache? = nil) throws -> Int64? {
|
|
| 2299 |
- try optionalInt64( |
|
| 2335 |
+ private func sampleTypeID( |
|
| 2336 |
+ typeIdentifier: String, |
|
| 2337 |
+ db: OpaquePointer?, |
|
| 2338 |
+ statementCache: SQLiteStatementCache? = nil, |
|
| 2339 |
+ lookupCache: SQLiteWriteLookupCache? = nil |
|
| 2340 |
+ ) throws -> Int64? {
|
|
| 2341 |
+ if let cached = lookupCache?.sampleTypeIDs[typeIdentifier] {
|
|
| 2342 |
+ return cached |
|
| 2343 |
+ } |
|
| 2344 |
+ let id = try optionalInt64( |
|
| 2300 | 2345 |
"SELECT id FROM sample_types WHERE type_identifier = ? LIMIT 1", |
| 2301 | 2346 |
db: db, |
| 2302 | 2347 |
statementCache: statementCache |
| 2303 | 2348 |
) { statement in
|
| 2304 | 2349 |
bindText(typeIdentifier, to: 1, in: statement) |
| 2305 | 2350 |
} |
| 2351 |
+ if let id {
|
|
| 2352 |
+ lookupCache?.sampleTypeIDs[typeIdentifier] = id |
|
| 2353 |
+ } |
|
| 2354 |
+ return id |
|
| 2306 | 2355 |
} |
| 2307 | 2356 |
|
| 2308 |
- private func upsertSourceRevision(_ row: ArchiveSampleRow, db: OpaquePointer?, statementCache: SQLiteStatementCache? = nil) throws -> Int64? {
|
|
| 2357 |
+ private func upsertSourceRevision( |
|
| 2358 |
+ _ row: ArchiveSampleRow, |
|
| 2359 |
+ db: OpaquePointer?, |
|
| 2360 |
+ statementCache: SQLiteStatementCache? = nil, |
|
| 2361 |
+ lookupCache: SQLiteWriteLookupCache? = nil |
|
| 2362 |
+ ) throws -> Int64? {
|
|
| 2309 | 2363 |
guard row.sourceName != nil || row.sourceBundleIdentifier != nil else { return nil }
|
| 2310 | 2364 |
let sourceNameHash = row.sourceName.map { HashService.archiveContentHash(domain: "hp:v2:source_name", parts: [$0]) }
|
| 2365 |
+ let key = SQLiteWriteLookupSourceRevisionKey( |
|
| 2366 |
+ sourceNameHash: sourceNameHash, |
|
| 2367 |
+ bundleIdentifier: row.sourceBundleIdentifier, |
|
| 2368 |
+ productType: row.sourceProductType, |
|
| 2369 |
+ version: row.sourceVersion, |
|
| 2370 |
+ operatingSystemVersion: row.sourceOperatingSystemVersion |
|
| 2371 |
+ ) |
|
| 2372 |
+ if let cached = lookupCache?.sourceRevisionIDs[key] {
|
|
| 2373 |
+ return cached |
|
| 2374 |
+ } |
|
| 2311 | 2375 |
let sourceID = try upsertSource( |
| 2312 | 2376 |
sourceNameHash: sourceNameHash, |
| 2313 | 2377 |
bundleIdentifier: row.sourceBundleIdentifier, |
| 2314 | 2378 |
db: db, |
| 2315 |
- statementCache: statementCache |
|
| 2379 |
+ statementCache: statementCache, |
|
| 2380 |
+ lookupCache: lookupCache |
|
| 2316 | 2381 |
) |
| 2317 | 2382 |
if let existing = try sourceRevisionID( |
| 2318 | 2383 |
sourceID: sourceID, |
@@ -2322,6 +2387,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2322 | 2387 |
db: db, |
| 2323 | 2388 |
statementCache: statementCache |
| 2324 | 2389 |
) {
|
| 2390 |
+ lookupCache?.sourceRevisionIDs[key] = existing |
|
| 2325 | 2391 |
return existing |
| 2326 | 2392 |
} |
| 2327 | 2393 |
|
@@ -2338,7 +2404,9 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2338 | 2404 |
throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
| 2339 | 2405 |
} |
| 2340 | 2406 |
} |
| 2341 |
- return sqlite3_last_insert_rowid(db) |
|
| 2407 |
+ let id = sqlite3_last_insert_rowid(db) |
|
| 2408 |
+ lookupCache?.sourceRevisionIDs[key] = id |
|
| 2409 |
+ return id |
|
| 2342 | 2410 |
} |
| 2343 | 2411 |
|
| 2344 | 2412 |
private func sourceRevisionID( |
@@ -2371,7 +2439,20 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2371 | 2439 |
} |
| 2372 | 2440 |
} |
| 2373 | 2441 |
|
| 2374 |
- private func upsertSource(sourceNameHash: String?, bundleIdentifier: String?, db: OpaquePointer?, statementCache: SQLiteStatementCache? = nil) throws -> Int64 {
|
|
| 2442 |
+ private func upsertSource( |
|
| 2443 |
+ sourceNameHash: String?, |
|
| 2444 |
+ bundleIdentifier: String?, |
|
| 2445 |
+ db: OpaquePointer?, |
|
| 2446 |
+ statementCache: SQLiteStatementCache? = nil, |
|
| 2447 |
+ lookupCache: SQLiteWriteLookupCache? = nil |
|
| 2448 |
+ ) throws -> Int64 {
|
|
| 2449 |
+ let key = SQLiteWriteLookupSourceKey( |
|
| 2450 |
+ sourceNameHash: sourceNameHash, |
|
| 2451 |
+ bundleIdentifier: bundleIdentifier |
|
| 2452 |
+ ) |
|
| 2453 |
+ if let cached = lookupCache?.sourceIDs[key] {
|
|
| 2454 |
+ return cached |
|
| 2455 |
+ } |
|
| 2375 | 2456 |
if let existing = try optionalInt64( |
| 2376 | 2457 |
""" |
| 2377 | 2458 |
SELECT id FROM sources |
@@ -2388,6 +2469,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2388 | 2469 |
bindText(bundleIdentifier, to: 4, in: statement) |
| 2389 | 2470 |
} |
| 2390 | 2471 |
) {
|
| 2472 |
+ lookupCache?.sourceIDs[key] = existing |
|
| 2391 | 2473 |
return existing |
| 2392 | 2474 |
} |
| 2393 | 2475 |
try withStatement( |
@@ -2401,15 +2483,31 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2401 | 2483 |
throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
| 2402 | 2484 |
} |
| 2403 | 2485 |
} |
| 2404 |
- return sqlite3_last_insert_rowid(db) |
|
| 2486 |
+ let id = sqlite3_last_insert_rowid(db) |
|
| 2487 |
+ lookupCache?.sourceIDs[key] = id |
|
| 2488 |
+ return id |
|
| 2405 | 2489 |
} |
| 2406 | 2490 |
|
| 2407 |
- private func upsertDevice(_ row: ArchiveSampleRow, db: OpaquePointer?, statementCache: SQLiteStatementCache? = nil) throws -> Int64? {
|
|
| 2491 |
+ private func upsertDevice( |
|
| 2492 |
+ _ row: ArchiveSampleRow, |
|
| 2493 |
+ db: OpaquePointer?, |
|
| 2494 |
+ statementCache: SQLiteStatementCache? = nil, |
|
| 2495 |
+ lookupCache: SQLiteWriteLookupCache? = nil |
|
| 2496 |
+ ) throws -> Int64? {
|
|
| 2408 | 2497 |
guard row.hasDeviceProvenance else { return nil }
|
| 2409 | 2498 |
let deviceHash = row.deviceName.map { HashService.archiveContentHash(domain: "hp:v2:device_name", parts: [$0]) }
|
| 2410 | 2499 |
let manufacturerHash = row.deviceManufacturer.map { HashService.archiveContentHash(domain: "hp:v2:device_manufacturer", parts: [$0]) }
|
| 2411 | 2500 |
let localIdentifierHash = row.deviceLocalIdentifier.map { HashService.archiveContentHash(domain: "hp:v2:device_local_id", parts: [$0]) }
|
| 2412 | 2501 |
let udiHash = row.deviceUDI.map { HashService.archiveContentHash(domain: "hp:v2:device_udi", parts: [$0]) }
|
| 2502 |
+ let key = SQLiteWriteLookupDeviceKey( |
|
| 2503 |
+ deviceHash: deviceHash, |
|
| 2504 |
+ localIdentifierHash: localIdentifierHash, |
|
| 2505 |
+ udiHash: udiHash, |
|
| 2506 |
+ model: row.deviceModel |
|
| 2507 |
+ ) |
|
| 2508 |
+ if let cached = lookupCache?.deviceIDs[key] {
|
|
| 2509 |
+ return cached |
|
| 2510 |
+ } |
|
| 2413 | 2511 |
|
| 2414 | 2512 |
if let existing = try optionalInt64( |
| 2415 | 2513 |
""" |
@@ -2433,6 +2531,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2433 | 2531 |
bindText(row.deviceModel, to: 8, in: statement) |
| 2434 | 2532 |
} |
| 2435 | 2533 |
) {
|
| 2534 |
+ lookupCache?.deviceIDs[key] = existing |
|
| 2436 | 2535 |
return existing |
| 2437 | 2536 |
} |
| 2438 | 2537 |
|
@@ -2458,11 +2557,21 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2458 | 2557 |
throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
| 2459 | 2558 |
} |
| 2460 | 2559 |
} |
| 2461 |
- return sqlite3_last_insert_rowid(db) |
|
| 2560 |
+ let id = sqlite3_last_insert_rowid(db) |
|
| 2561 |
+ lookupCache?.deviceIDs[key] = id |
|
| 2562 |
+ return id |
|
| 2462 | 2563 |
} |
| 2463 | 2564 |
|
| 2464 |
- private func upsertMetadataBlob(_ row: ArchiveSampleRow, db: OpaquePointer?, statementCache: SQLiteStatementCache? = nil) throws -> Int64? {
|
|
| 2565 |
+ private func upsertMetadataBlob( |
|
| 2566 |
+ _ row: ArchiveSampleRow, |
|
| 2567 |
+ db: OpaquePointer?, |
|
| 2568 |
+ statementCache: SQLiteStatementCache? = nil, |
|
| 2569 |
+ lookupCache: SQLiteWriteLookupCache? = nil |
|
| 2570 |
+ ) throws -> Int64? {
|
|
| 2465 | 2571 |
guard let metadataHash = row.metadataHash, let metadataJSON = row.metadataJSON else { return nil }
|
| 2572 |
+ if let cached = lookupCache?.metadataBlobIDs[metadataHash] {
|
|
| 2573 |
+ return cached |
|
| 2574 |
+ } |
|
| 2466 | 2575 |
try withStatement( |
| 2467 | 2576 |
"INSERT OR IGNORE INTO metadata_blobs (metadata_hash, metadata_json) VALUES (?, ?)", |
| 2468 | 2577 |
db: db, |
@@ -2474,13 +2583,15 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2474 | 2583 |
throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
| 2475 | 2584 |
} |
| 2476 | 2585 |
} |
| 2477 |
- return try requiredInt64( |
|
| 2586 |
+ let id = try requiredInt64( |
|
| 2478 | 2587 |
"SELECT id FROM metadata_blobs WHERE metadata_hash = ? LIMIT 1", |
| 2479 | 2588 |
db: db, |
| 2480 | 2589 |
statementCache: statementCache |
| 2481 | 2590 |
) { statement in
|
| 2482 | 2591 |
bindText(metadataHash, to: 1, in: statement) |
| 2483 | 2592 |
} |
| 2593 |
+ lookupCache?.metadataBlobIDs[metadataHash] = id |
|
| 2594 |
+ return id |
|
| 2484 | 2595 |
} |
| 2485 | 2596 |
|
| 2486 | 2597 |
private func upsertSample( |
@@ -3048,6 +3159,36 @@ private final class SQLiteStatementCache: @unchecked Sendable {
|
||
| 3048 | 3159 |
} |
| 3049 | 3160 |
} |
| 3050 | 3161 |
|
| 3162 |
+private struct SQLiteWriteLookupSourceKey: Hashable, Sendable {
|
|
| 3163 |
+ let sourceNameHash: String? |
|
| 3164 |
+ let bundleIdentifier: String? |
|
| 3165 |
+} |
|
| 3166 |
+ |
|
| 3167 |
+private struct SQLiteWriteLookupSourceRevisionKey: Hashable, Sendable {
|
|
| 3168 |
+ let sourceNameHash: String? |
|
| 3169 |
+ let bundleIdentifier: String? |
|
| 3170 |
+ let productType: String? |
|
| 3171 |
+ let version: String? |
|
| 3172 |
+ let operatingSystemVersion: String? |
|
| 3173 |
+} |
|
| 3174 |
+ |
|
| 3175 |
+private struct SQLiteWriteLookupDeviceKey: Hashable, Sendable {
|
|
| 3176 |
+ let deviceHash: String? |
|
| 3177 |
+ let localIdentifierHash: String? |
|
| 3178 |
+ let udiHash: String? |
|
| 3179 |
+ let model: String? |
|
| 3180 |
+} |
|
| 3181 |
+ |
|
| 3182 |
+private final class SQLiteWriteLookupCache: @unchecked Sendable {
|
|
| 3183 |
+ nonisolated init() {}
|
|
| 3184 |
+ |
|
| 3185 |
+ nonisolated(unsafe) var sampleTypeIDs: [String: Int64] = [:] |
|
| 3186 |
+ nonisolated(unsafe) var sourceIDs: [SQLiteWriteLookupSourceKey: Int64] = [:] |
|
| 3187 |
+ nonisolated(unsafe) var sourceRevisionIDs: [SQLiteWriteLookupSourceRevisionKey: Int64] = [:] |
|
| 3188 |
+ nonisolated(unsafe) var deviceIDs: [SQLiteWriteLookupDeviceKey: Int64] = [:] |
|
| 3189 |
+ nonisolated(unsafe) var metadataBlobIDs: [String: Int64] = [:] |
|
| 3190 |
+} |
|
| 3191 |
+ |
|
| 3051 | 3192 |
private struct ArchiveSampleRow {
|
| 3052 | 3193 |
let sampleUUIDHash: String |
| 3053 | 3194 |
let typeIdentifier: String |
@@ -65,13 +65,14 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 65 | 65 |
)) |
| 66 | 66 |
let report = try await store.checkIntegrity() |
| 67 | 67 |
let versionDebugRows = try sampleVersionDebugRows(at: url) |
| 68 |
+ let visibilityDebugRows = try visibilityRangeDebugRows(at: url) |
|
| 68 | 69 |
|
| 69 | 70 |
XCTAssertEqual(firstWrite.insertedCount, 1) |
| 70 | 71 |
XCTAssertEqual(firstWrite.updatedCount, 0) |
| 71 | 72 |
XCTAssertEqual(firstWrite.unchangedCount, 0) |
| 72 | 73 |
XCTAssertEqual(try countRows(in: "samples", at: url), 1) |
| 73 | 74 |
XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows) |
| 74 |
- XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1) |
|
| 75 |
+ XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1, visibilityDebugRows) |
|
| 75 | 76 |
XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1) |
| 76 | 77 |
XCTAssertFalse(try tableExists("archive_samples", at: url))
|
| 77 | 78 |
XCTAssertEqual(secondWrite.insertedCount, 0) |
@@ -692,4 +693,39 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 692 | 693 |
} |
| 693 | 694 |
return rows.joined(separator: "\n") |
| 694 | 695 |
} |
| 696 |
+ |
|
| 697 |
+ private func visibilityRangeDebugRows(at url: URL) throws -> String {
|
|
| 698 |
+ var db: OpaquePointer? |
|
| 699 |
+ guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|
|
| 700 |
+ sqlite3_close(db) |
|
| 701 |
+ return "could not open database" |
|
| 702 |
+ } |
|
| 703 |
+ defer { sqlite3_close(db) }
|
|
| 704 |
+ |
|
| 705 |
+ let sql = """ |
|
| 706 |
+ SELECT sample_id, version_id, first_observation_id, last_observation_id, first_seen_at, last_seen_at |
|
| 707 |
+ FROM sample_visibility_ranges |
|
| 708 |
+ ORDER BY sample_id, version_id, first_observation_id |
|
| 709 |
+ """ |
|
| 710 |
+ var statement: OpaquePointer? |
|
| 711 |
+ guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
|
|
| 712 |
+ sqlite3_finalize(statement) |
|
| 713 |
+ return "could not prepare visibility debug query" |
|
| 714 |
+ } |
|
| 715 |
+ defer { sqlite3_finalize(statement) }
|
|
| 716 |
+ |
|
| 717 |
+ var rows: [String] = [] |
|
| 718 |
+ while sqlite3_step(statement) == SQLITE_ROW {
|
|
| 719 |
+ rows.append((0..<6).map { index in
|
|
| 720 |
+ if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
|
|
| 721 |
+ return "null" |
|
| 722 |
+ } |
|
| 723 |
+ if let text = sqlite3_column_text(statement, Int32(index)) {
|
|
| 724 |
+ return String(cString: text) |
|
| 725 |
+ } |
|
| 726 |
+ return "\(sqlite3_column_double(statement, Int32(index)))" |
|
| 727 |
+ }.joined(separator: "|")) |
|
| 728 |
+ } |
|
| 729 |
+ return rows.joined(separator: "\n") |
|
| 730 |
+ } |
|
| 695 | 731 |
} |