Showing 3 changed files with 200 additions and 23 deletions
+1 -1
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -25,7 +25,7 @@ There are no real deployments, only test installations. Existing prototype datab
25 25
 |------|----------------|--------------------|
26 26
 | Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index |
27 27
 | HealthKit capture | 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 |
+162 -21
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -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
+37 -1
HealthProbeTests/SQLiteHealthArchiveStoreTests.swift
@@ -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
 }