Showing 7 changed files with 607 additions and 40 deletions
+7 -7
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -28,9 +28,9 @@ There are no real deployments, only test installations. Existing prototype datab
28 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 | Move Snapshots/Data Types from SwiftData previews 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 | Treat as disposable prototype data; reset/ignore during v2 transition |
31
-| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Finish export previews on paged SQLite DTOs |
31
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; export preview reads the archive export API before showing/exporting JSON; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Add legacy/small-device simplifications and remove remaining SwiftData navigation handles |
32 32
 | Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications |
33
-| Export | Prototype scoped JSON export exists | Add recovery-compatible manifests and streaming/paged export |
33
+| Export | SQLite export preview, paged JSON writing, SHA256 manifest hashing, and `export_manifests` rows are in place for selected records and observation diffs | Fill remaining recovery-compatible envelope metadata, CSV export, relationship preservation, and reproducibility checks |
34 34
 | Legacy device support | Not implemented | Remove SwiftData dependency and simplify heavy views for low-memory devices |
35 35
 | Recovery workflows | Not supported | Preserve export/archive structure for external recovery tools only |
36 36
 
@@ -41,7 +41,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
41 41
 1. Move Snapshots/Data Types from SwiftData model reads to Core Data/cache DTOs.
42 42
 2. Add targeted cache invalidation for affected observation/type ranges.
43 43
 3. Finish remaining UI language cleanup from anomaly/status to observation/diff/export where legacy model names still leak into active flows.
44
-4. Add streaming exports with manifests.
44
+4. Complete recovery-compatible export metadata, CSV output, and reproducibility checks.
45 45
 5. Validate on low-memory/legacy-class devices.
46 46
 
47 47
 ## Known Prototype Mismatches
@@ -49,12 +49,12 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
49 49
 - SwiftData currently blocks iOS 15-era device support.
50 50
 - Existing `Anomaly*` model/service names are legacy language.
51 51
 - Some screens still imply snapshot-count monitoring rather than Time Machine inspection.
52
-- Current UI/cache layers still depend on SwiftData prototype models for capture review actions, navigation handles, some charts, and export/PDF paths.
52
+- Current UI/cache layers still depend on SwiftData prototype models for capture review actions, navigation handles, some charts, and PDF paths.
53 53
 - Snapshots timeline, snapshot detail summary/type rows, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but navigation still uses SwiftData snapshot handles during the transition.
54 54
 - Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated.
55 55
 - Existing implementation may decode or cache too much data for low-end devices.
56 56
 - Old prototype database compatibility is no longer required.
57
-- Initial SQLite archive tests cover open/init/reset/idempotency, snapshot-level observation grouping, legacy mirror removal, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, and consolidation-evidence labels, but not yet export behavior.
57
+- Initial SQLite archive tests cover open/init/reset/idempotency, snapshot-level observation grouping, legacy mirror removal, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, consolidation-evidence labels, export preview, paged JSON output, and manifest row persistence.
58 58
 - Initial Core Data cache tests cover full rebuild from SQLite and delete-cache-then-rebuild without losing archive data.
59 59
 
60 60
 ## Verification Checklist
@@ -70,8 +70,8 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
70 70
 - [x] Expensive counts used by reports/UI are cached and rebuildable.
71 71
 - [x] Deleting Core Data cache and rebuilding from SQLite restores UI/report summaries.
72 72
 - [x] Dashboard surfaces SQLite/Core Data cache health, cache schema, cache errors, and latest archive observation counts.
73
-- [ ] Export can stream large selected record sets.
74
-- [ ] Export manifests include hashes and observation metadata.
73
+- [x] Export can stream/page selected record sets without materializing the full record list.
74
+- [x] Export manifests include hashes and diff observation metadata when applicable.
75 75
 - [ ] iOS app remains read-only with respect to HealthKit.
76 76
 - [ ] Docs and UI do not claim in-app restore/re-publication support.
77 77
 - [ ] Legacy/small-device UI mode preserves capture/report/export.
+8 -8
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -196,21 +196,21 @@ Acceptance:
196 196
 **Purpose:** Produce scoped, recovery-compatible exports.
197 197
 
198 198
 Checklist:
199
-- [ ] Define JSON export envelope.
199
+- [x] Define JSON export envelope.
200 200
 - [ ] Define CSV record-table export.
201
-- [ ] Define manifest hash algorithm.
201
+- [x] Define manifest hash algorithm.
202 202
 - [ ] Include archive/app/schema/observation metadata.
203 203
 - [ ] Include sample identity and payload version hashes.
204
-- [ ] Include values/dates/units/type fields.
205
-- [ ] Include source/provenance metadata where available and allowed.
204
+- [x] Include values/dates/units/type fields.
205
+- [x] Include source/provenance metadata where available and allowed.
206 206
 - [ ] Include relationships where available.
207 207
 - [ ] Include provenance-loss warning for external HealthKit re-publication.
208
-- [ ] Stream/page export from SQLite.
209
-- [ ] Store export manifest rows.
208
+- [x] Stream/page export from SQLite.
209
+- [x] Store export manifest rows.
210 210
 - [ ] Add reproducibility test for export manifests.
211 211
 
212 212
 Acceptance:
213
-- [ ] Large export does not materialize full record set in RAM.
213
+- [x] Large export does not materialize full record set in RAM.
214 214
 - [ ] Export can be verified against archive hashes.
215 215
 - [ ] Export contains enough structure for external recovery/salvage tooling.
216 216
 - [ ] App still does not perform restore, backup patching, or HealthKit re-publication.
@@ -229,7 +229,7 @@ Checklist:
229 229
 - [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist.
230 230
 - [x] Diff detail fully uses cached summary plus paged SQLite DTOs.
231 231
 - [x] Data type screens use target change labels.
232
-- [ ] Export preview uses export query/manifest APIs.
232
+- [x] Export preview uses export query/manifest APIs.
233 233
 - [x] Archive status reflects SQLite/Core Data cache health.
234 234
 - [ ] Legacy/small-device UI mode simplifies heavy visualizations.
235 235
 
+33 -1
HealthProbe/Services/Protocols/HealthArchiveStore.swift
@@ -17,6 +17,7 @@ protocol HealthArchiveStore {
17 17
     func aggregateComparison(_ request: HealthArchiveAggregateComparisonRequest) async throws -> [HealthArchiveAggregateComparisonRow]
18 18
     func sourceProvenanceBreakdown(_ request: HealthArchiveSourceProvenanceRequest) async throws -> [HealthArchiveSourceProvenanceRow]
19 19
     func consolidationEvidence(_ request: HealthArchiveConsolidationEvidenceRequest) async throws -> [HealthArchiveConsolidationEvidence]
20
+    func exportPreview(_ request: HealthArchiveReportRequest) async throws -> HealthArchiveExportPreview
20 21
     func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL
21 22
     func checkIntegrity() async throws -> HealthArchiveIntegrityReport
22 23
 }
@@ -236,6 +237,9 @@ struct HealthArchiveReportRequest: Equatable, Sendable {
236 237
     let disappearedOnly: Bool
237 238
     let firstSeenAfter: Date?
238 239
     let firstSeenBefore: Date?
240
+    let diffFromObservationID: Int64?
241
+    let diffToObservationID: Int64?
242
+    let diffKind: HealthArchiveDiffKind?
239 243
 
240 244
     init(
241 245
         reportID: UUID,
@@ -244,7 +248,10 @@ struct HealthArchiveReportRequest: Equatable, Sendable {
244 248
         typeIdentifierFilter: String? = nil,
245 249
         disappearedOnly: Bool = false,
246 250
         firstSeenAfter: Date? = nil,
247
-        firstSeenBefore: Date? = nil
251
+        firstSeenBefore: Date? = nil,
252
+        diffFromObservationID: Int64? = nil,
253
+        diffToObservationID: Int64? = nil,
254
+        diffKind: HealthArchiveDiffKind? = nil
248 255
     ) {
249 256
         self.reportID = reportID
250 257
         self.title = title
@@ -253,9 +260,34 @@ struct HealthArchiveReportRequest: Equatable, Sendable {
253 260
         self.disappearedOnly = disappearedOnly
254 261
         self.firstSeenAfter = firstSeenAfter
255 262
         self.firstSeenBefore = firstSeenBefore
263
+        self.diffFromObservationID = diffFromObservationID
264
+        self.diffToObservationID = diffToObservationID
265
+        self.diffKind = diffKind
256 266
     }
257 267
 }
258 268
 
269
+struct HealthArchiveExportPreview: Equatable, Sendable {
270
+    let reportID: UUID
271
+    let title: String
272
+    let exportKind: String
273
+    let estimatedRecordCount: Int
274
+    let typeIdentifierFilter: String?
275
+    let disappearedOnly: Bool
276
+    let firstSeenAfter: Date?
277
+    let firstSeenBefore: Date?
278
+    let diffFromObservationID: Int64?
279
+    let diffToObservationID: Int64?
280
+    let diffKind: HealthArchiveDiffKind?
281
+}
282
+
283
+struct HealthArchiveExportManifest: Equatable, Sendable {
284
+    let exportID: UUID
285
+    let exportKind: String
286
+    let createdAt: Date
287
+    let recordCount: Int
288
+    let manifestHash: String
289
+}
290
+
259 291
 struct ArchivedHealthRecord: Identifiable, Equatable, Sendable, Encodable {
260 292
     let id: String
261 293
     let sampleTypeIdentifier: String
+449 -18
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -1,3 +1,4 @@
1
+import CryptoKit
1 2
 import Foundation
2 3
 import HealthKit
3 4
 import SQLite3
@@ -952,8 +953,45 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
952 953
         }
953 954
     }
954 955
 
956
+    func exportPreview(_ request: HealthArchiveReportRequest) async throws -> HealthArchiveExportPreview {
957
+        let db = try openDatabase()
958
+        defer { sqlite3_close(db) }
959
+        try prepareSchemaIfNeeded(db)
960
+
961
+        let recordCount = try await exportRecordCount(for: request, db: db)
962
+        return HealthArchiveExportPreview(
963
+            reportID: request.reportID,
964
+            title: request.title,
965
+            exportKind: "observation_records_json",
966
+            estimatedRecordCount: recordCount,
967
+            typeIdentifierFilter: request.typeIdentifierFilter,
968
+            disappearedOnly: request.disappearedOnly,
969
+            firstSeenAfter: request.firstSeenAfter,
970
+            firstSeenBefore: request.firstSeenBefore,
971
+            diffFromObservationID: request.diffFromObservationID,
972
+            diffToObservationID: request.diffToObservationID,
973
+            diffKind: request.diffKind
974
+        )
975
+    }
976
+
955 977
     func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL {
956
-        let recordRequest = HealthArchiveRecordRequest(
978
+        let exportedAt = Date()
979
+        let manifest = try await buildExportManifest(request: request, exportedAt: exportedAt)
980
+        let exportURL = URL.temporaryDirectory
981
+            .appending(path: "HealthProbe-\(request.reportID.uuidString)")
982
+            .appendingPathExtension("json")
983
+        try await writeExportReport(
984
+            request: request,
985
+            manifest: manifest,
986
+            exportedAt: exportedAt,
987
+            to: exportURL
988
+        )
989
+        try recordExportManifest(request: request, manifest: manifest)
990
+        return exportURL
991
+    }
992
+
993
+    private func recordRequest(from request: HealthArchiveReportRequest) -> HealthArchiveRecordRequest {
994
+        HealthArchiveRecordRequest(
957 995
             sampleTypeIdentifier: request.typeIdentifierFilter,
958 996
             fingerprints: request.includedFingerprints,
959 997
             disappearedOnly: request.disappearedOnly,
@@ -961,19 +999,365 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
961 999
             firstSeenBefore: request.firstSeenBefore,
962 1000
             limit: nil
963 1001
         )
964
-        let records = try await records(for: recordRequest)
965
-        let payload = HealthArchiveReportPayload(
966
-            reportID: request.reportID,
967
-            title: request.title,
968
-            exportedAt: Date(),
969
-            records: records
1002
+    }
1003
+
1004
+    private func exportRecordCount(for request: HealthArchiveReportRequest, db: OpaquePointer?) async throws -> Int {
1005
+        if let fromObservationID = request.diffFromObservationID,
1006
+           let toObservationID = request.diffToObservationID,
1007
+           let diffKind = request.diffKind {
1008
+            let summary = try await diffSummary(HealthArchiveDiffRequest(
1009
+                fromObservationID: fromObservationID,
1010
+                toObservationID: toObservationID,
1011
+                sampleTypeIdentifier: request.typeIdentifierFilter
1012
+            ))
1013
+            switch diffKind {
1014
+            case .appeared:
1015
+                return summary.appearedCount
1016
+            case .disappeared:
1017
+                return summary.disappearedCount
1018
+            case .representationChanged:
1019
+                return summary.representationChangedCount
1020
+            }
1021
+        }
1022
+
1023
+        return try exportRecordCount(for: recordRequest(from: request), db: db)
1024
+    }
1025
+
1026
+    private func exportRecordCount(for request: HealthArchiveRecordRequest, db: OpaquePointer?) throws -> Int {
1027
+        var clauses: [String] = []
1028
+        if request.sampleTypeIdentifier != nil {
1029
+            clauses.append("t.type_identifier = ?")
1030
+        }
1031
+        if !request.fingerprints.isEmpty {
1032
+            clauses.append("s.strict_fingerprint IN (\(Array(repeating: "?", count: request.fingerprints.count).joined(separator: ",")))")
1033
+        }
1034
+        if request.disappearedOnly {
1035
+            clauses.append("rr.last_observation_id IS NOT NULL AND es.disappeared_at IS NOT NULL")
1036
+        }
1037
+        if request.firstSeenAfter != nil {
1038
+            clauses.append("s.first_seen_at >= ?")
1039
+        }
1040
+        if request.firstSeenBefore != nil {
1041
+            clauses.append("s.first_seen_at <= ?")
1042
+        }
1043
+        let whereClause = clauses.isEmpty ? "" : "WHERE \(clauses.joined(separator: " AND "))"
1044
+        let sql = """
1045
+        WITH selected_ranges AS (
1046
+            SELECT
1047
+                r.sample_id, r.version_id, r.first_observation_id, r.last_observation_id,
1048
+                r.first_seen_at, r.last_seen_at,
1049
+                ROW_NUMBER() OVER (
1050
+                    PARTITION BY r.sample_id
1051
+                    ORDER BY
1052
+                        CASE
1053
+                            WHEN ? IS NOT NULL THEN 0
1054
+                            WHEN r.last_observation_id IS NULL THEN 0
1055
+                            ELSE 1
1056
+                        END,
1057
+                        COALESCE(r.last_observation_id, 9223372036854775807) DESC,
1058
+                        r.first_observation_id DESC
1059
+                ) AS record_rank
1060
+            FROM sample_visibility_ranges r
1061
+            WHERE (? IS NULL OR (
1062
+                r.first_observation_id <= ?
1063
+                AND (r.last_observation_id IS NULL OR r.last_observation_id > ?)
1064
+            ))
1065
+        ),
1066
+        record_ranges AS (
1067
+            SELECT *
1068
+            FROM selected_ranges
1069
+            WHERE record_rank = 1
1070
+        ),
1071
+        event_summary AS (
1072
+            SELECT
1073
+                sample_id,
1074
+                MAX(CASE WHEN event_kind != 'disappeared' THEN observed_at END) AS last_seen_at,
1075
+                MAX(observed_at) AS last_verified_at,
1076
+                MAX(CASE WHEN event_kind = 'disappeared' THEN observed_at END) AS disappeared_at
1077
+            FROM sample_observation_events
1078
+            GROUP BY sample_id
970 1079
         )
971
-        let data = try JSONEncoder.healthArchive.encode(payload)
972
-        let exportURL = URL.temporaryDirectory
973
-            .appending(path: "HealthProbe-\(request.reportID.uuidString)")
974
-            .appendingPathExtension("json")
975
-        try data.write(to: exportURL, options: [.atomic])
976
-        return exportURL
1080
+        SELECT COUNT(*)
1081
+        FROM record_ranges rr
1082
+        JOIN samples s ON s.id = rr.sample_id
1083
+        JOIN sample_types t ON t.id = s.sample_type_id
1084
+        JOIN sample_versions v ON v.id = rr.version_id
1085
+        LEFT JOIN event_summary es ON es.sample_id = s.id
1086
+        \(whereClause)
1087
+        """
1088
+
1089
+        return try withStatement(sql, db: db) { statement in
1090
+            var index: Int32 = 1
1091
+            try bindRecordFilterValues(request, to: statement, startingAt: &index, includeCursor: false)
1092
+            guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
1093
+            return columnInt(statement, 0) ?? 0
1094
+        }
1095
+    }
1096
+
1097
+    private func bindRecordFilterValues(
1098
+        _ request: HealthArchiveRecordRequest,
1099
+        to statement: OpaquePointer?,
1100
+        startingAt index: inout Int32,
1101
+        includeCursor: Bool
1102
+    ) throws {
1103
+        bindInt64(request.visibleAtObservationID, to: index, in: statement)
1104
+        index += 1
1105
+        bindInt64(request.visibleAtObservationID, to: index, in: statement)
1106
+        index += 1
1107
+        bindInt64(request.visibleAtObservationID, to: index, in: statement)
1108
+        index += 1
1109
+        bindInt64(request.visibleAtObservationID, to: index, in: statement)
1110
+        index += 1
1111
+        if let typeIdentifier = request.sampleTypeIdentifier {
1112
+            bindText(typeIdentifier, to: index, in: statement)
1113
+            index += 1
1114
+        }
1115
+        for fingerprint in request.fingerprints.sorted() {
1116
+            bindText(fingerprint, to: index, in: statement)
1117
+            index += 1
1118
+        }
1119
+        if let firstSeenAfter = request.firstSeenAfter {
1120
+            sqlite3_bind_double(statement, index, firstSeenAfter.timeIntervalSince1970)
1121
+            index += 1
1122
+        }
1123
+        if let firstSeenBefore = request.firstSeenBefore {
1124
+            sqlite3_bind_double(statement, index, firstSeenBefore.timeIntervalSince1970)
1125
+            index += 1
1126
+        }
1127
+        if includeCursor, let cursor = request.afterCursor {
1128
+            sqlite3_bind_double(statement, index, cursor.startDate.timeIntervalSince1970)
1129
+            index += 1
1130
+            sqlite3_bind_double(statement, index, cursor.startDate.timeIntervalSince1970)
1131
+            index += 1
1132
+            bindText(cursor.strictFingerprint, to: index, in: statement)
1133
+            index += 1
1134
+        }
1135
+    }
1136
+
1137
+    private func buildExportManifest(
1138
+        request: HealthArchiveReportRequest,
1139
+        exportedAt: Date
1140
+    ) async throws -> HealthArchiveExportManifest {
1141
+        var hasher = SHA256()
1142
+        hasher.update(data: Data("hp:export:manifest:v1".utf8))
1143
+        hasher.update(data: Data(request.reportID.uuidString.utf8))
1144
+        hasher.update(data: Data(request.title.utf8))
1145
+        hasher.update(data: Data(exportedAt.timeIntervalSince1970.description.utf8))
1146
+        hasher.update(data: Data((request.typeIdentifierFilter ?? "*").utf8))
1147
+        hasher.update(data: Data(request.disappearedOnly.description.utf8))
1148
+        if let firstSeenAfter = request.firstSeenAfter {
1149
+            hasher.update(data: Data(firstSeenAfter.timeIntervalSince1970.description.utf8))
1150
+        }
1151
+        if let firstSeenBefore = request.firstSeenBefore {
1152
+            hasher.update(data: Data(firstSeenBefore.timeIntervalSince1970.description.utf8))
1153
+        }
1154
+        if let diffFromObservationID = request.diffFromObservationID {
1155
+            hasher.update(data: Data(diffFromObservationID.description.utf8))
1156
+        }
1157
+        if let diffToObservationID = request.diffToObservationID {
1158
+            hasher.update(data: Data(diffToObservationID.description.utf8))
1159
+        }
1160
+        if let diffKind = request.diffKind {
1161
+            hasher.update(data: Data(diffKind.rawValue.utf8))
1162
+        }
1163
+
1164
+        var recordCount = 0
1165
+        try await forEachReportRecord(request) { record in
1166
+            recordCount += 1
1167
+            let data = try JSONEncoder.healthArchive.encode(ExportRecordPayload(record))
1168
+            let itemHash = Self.hexDigest(SHA256.hash(data: data))
1169
+            hasher.update(data: Data(itemHash.utf8))
1170
+        }
1171
+
1172
+        let manifestHash = Self.hexDigest(hasher.finalize())
1173
+        return HealthArchiveExportManifest(
1174
+            exportID: request.reportID,
1175
+            exportKind: "observation_records_json",
1176
+            createdAt: exportedAt,
1177
+            recordCount: recordCount,
1178
+            manifestHash: manifestHash
1179
+        )
1180
+    }
1181
+
1182
+    private func writeExportReport(
1183
+        request: HealthArchiveReportRequest,
1184
+        manifest: HealthArchiveExportManifest,
1185
+        exportedAt: Date,
1186
+        to exportURL: URL
1187
+    ) async throws {
1188
+        FileManager.default.createFile(atPath: exportURL.path, contents: nil)
1189
+        let handle = try FileHandle(forWritingTo: exportURL)
1190
+        defer { try? handle.close() }
1191
+
1192
+        try handle.write(contentsOf: Data("{\n".utf8))
1193
+        try writeJSONField("exportFormatVersion", value: "1", quoted: false, trailingComma: true, to: handle)
1194
+        try writeJSONField("reportID", value: request.reportID.uuidString, trailingComma: true, to: handle)
1195
+        try writeJSONField("title", value: request.title, trailingComma: true, to: handle)
1196
+        try writeJSONField("exportedAt", value: JSONEncoder.healthArchiveDateString(exportedAt), trailingComma: true, to: handle)
1197
+        try handle.write(contentsOf: Data("  \"manifest\" : {\n".utf8))
1198
+        try writeJSONField("exportKind", value: manifest.exportKind, trailingComma: true, indent: "    ", to: handle)
1199
+        try writeJSONField("recordCount", value: "\(manifest.recordCount)", quoted: false, trailingComma: true, indent: "    ", to: handle)
1200
+        try writeJSONField("manifestHash", value: manifest.manifestHash, trailingComma: false, indent: "    ", to: handle)
1201
+        try handle.write(contentsOf: Data("  },\n".utf8))
1202
+        try handle.write(contentsOf: Data("  \"records\" : [\n".utf8))
1203
+
1204
+        var isFirstRecord = true
1205
+        try await forEachReportRecord(request) { record in
1206
+            if isFirstRecord {
1207
+                isFirstRecord = false
1208
+            } else {
1209
+                try handle.write(contentsOf: Data(",\n".utf8))
1210
+            }
1211
+            let data = try JSONEncoder.healthArchive.encode(ExportRecordPayload(record))
1212
+            try handle.write(contentsOf: data)
1213
+        }
1214
+
1215
+        try handle.write(contentsOf: Data("\n  ]\n}\n".utf8))
1216
+    }
1217
+
1218
+    private func forEachReportRecord(
1219
+        _ request: HealthArchiveReportRequest,
1220
+        _ body: (ArchivedHealthRecord) throws -> Void
1221
+    ) async throws {
1222
+        if let fromObservationID = request.diffFromObservationID,
1223
+           let toObservationID = request.diffToObservationID,
1224
+           let diffKind = request.diffKind {
1225
+            try await forEachDiffExportRecord(
1226
+                HealthArchiveDiffRecordRequest(
1227
+                    fromObservationID: fromObservationID,
1228
+                    toObservationID: toObservationID,
1229
+                    sampleTypeIdentifier: request.typeIdentifierFilter,
1230
+                    kind: diffKind
1231
+                ),
1232
+                body
1233
+            )
1234
+            return
1235
+        }
1236
+
1237
+        try await forEachExportRecord(recordRequest(from: request), body)
1238
+    }
1239
+
1240
+    private func writeJSONField(
1241
+        _ key: String,
1242
+        value: String,
1243
+        quoted: Bool = true,
1244
+        trailingComma: Bool,
1245
+        indent: String = "  ",
1246
+        to handle: FileHandle
1247
+    ) throws {
1248
+        let encodedValue: String
1249
+        if quoted {
1250
+            let data = try JSONEncoder.healthArchive.encode(value)
1251
+            encodedValue = String(decoding: data, as: UTF8.self)
1252
+        } else {
1253
+            encodedValue = value
1254
+        }
1255
+        let comma = trailingComma ? "," : ""
1256
+        try handle.write(contentsOf: Data("\(indent)\"\(key)\" : \(encodedValue)\(comma)\n".utf8))
1257
+    }
1258
+
1259
+    private func forEachExportRecord(
1260
+        _ request: HealthArchiveRecordRequest,
1261
+        _ body: (ArchivedHealthRecord) throws -> Void
1262
+    ) async throws {
1263
+        var cursor: RecordCursor?
1264
+        let pageSize = 500
1265
+        while true {
1266
+            let page = try await records(for: HealthArchiveRecordRequest(
1267
+                visibleAtObservationID: request.visibleAtObservationID,
1268
+                sampleTypeIdentifier: request.sampleTypeIdentifier,
1269
+                fingerprints: request.fingerprints,
1270
+                disappearedOnly: request.disappearedOnly,
1271
+                firstSeenAfter: request.firstSeenAfter,
1272
+                firstSeenBefore: request.firstSeenBefore,
1273
+                afterCursor: cursor,
1274
+                limit: pageSize
1275
+            ))
1276
+            guard !page.isEmpty else { break }
1277
+            for record in page {
1278
+                try body(record)
1279
+            }
1280
+            guard page.count == pageSize, let last = page.last else { break }
1281
+            cursor = RecordCursor(startDate: last.startDate, strictFingerprint: last.strictFingerprint)
1282
+        }
1283
+    }
1284
+
1285
+    private func forEachDiffExportRecord(
1286
+        _ request: HealthArchiveDiffRecordRequest,
1287
+        _ body: (ArchivedHealthRecord) throws -> Void
1288
+    ) async throws {
1289
+        var cursor: RecordCursor?
1290
+        let pageSize = 500
1291
+        while true {
1292
+            let page = try await diffRecords(HealthArchiveDiffRecordRequest(
1293
+                fromObservationID: request.fromObservationID,
1294
+                toObservationID: request.toObservationID,
1295
+                sampleTypeIdentifier: request.sampleTypeIdentifier,
1296
+                kind: request.kind,
1297
+                afterCursor: cursor,
1298
+                limit: pageSize
1299
+            ))
1300
+            guard !page.isEmpty else { break }
1301
+            for record in page {
1302
+                try body(record)
1303
+            }
1304
+            guard page.count == pageSize, let last = page.last else { break }
1305
+            cursor = RecordCursor(startDate: last.startDate, strictFingerprint: last.strictFingerprint)
1306
+        }
1307
+    }
1308
+
1309
+    private func recordExportManifest(
1310
+        request: HealthArchiveReportRequest,
1311
+        manifest: HealthArchiveExportManifest
1312
+    ) throws {
1313
+        let db = try openDatabase()
1314
+        defer { sqlite3_close(db) }
1315
+        try prepareSchemaIfNeeded(db)
1316
+
1317
+        let filterJSON = exportFilterJSON(request)
1318
+        let sql = """
1319
+        INSERT OR REPLACE INTO export_manifests (
1320
+            export_id, created_at, export_kind, from_observation_id, to_observation_id,
1321
+            filter_json, record_count, manifest_hash
1322
+        )
1323
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1324
+        """
1325
+        try withStatement(sql, db: db) { statement in
1326
+            bindText(manifest.exportID.uuidString, to: 1, in: statement)
1327
+            sqlite3_bind_double(statement, 2, manifest.createdAt.timeIntervalSince1970)
1328
+            bindText(manifest.exportKind, to: 3, in: statement)
1329
+            bindInt64(request.diffFromObservationID, to: 4, in: statement)
1330
+            bindInt64(request.diffToObservationID, to: 5, in: statement)
1331
+            bindText(filterJSON, to: 6, in: statement)
1332
+            sqlite3_bind_int64(statement, 7, Int64(manifest.recordCount))
1333
+            bindText(manifest.manifestHash, to: 8, in: statement)
1334
+            guard sqlite3_step(statement) == SQLITE_DONE else {
1335
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
1336
+            }
1337
+        }
1338
+    }
1339
+
1340
+    nonisolated private func exportFilterJSON(_ request: HealthArchiveReportRequest) -> String {
1341
+        let payload: [String: Any] = [
1342
+            "typeIdentifier": request.typeIdentifierFilter ?? "*",
1343
+            "disappearedOnly": request.disappearedOnly,
1344
+            "firstSeenAfter": request.firstSeenAfter?.timeIntervalSince1970 ?? NSNull(),
1345
+            "firstSeenBefore": request.firstSeenBefore?.timeIntervalSince1970 ?? NSNull(),
1346
+            "diffFromObservationID": request.diffFromObservationID ?? NSNull(),
1347
+            "diffToObservationID": request.diffToObservationID ?? NSNull(),
1348
+            "diffKind": request.diffKind?.rawValue ?? NSNull()
1349
+        ]
1350
+        guard
1351
+            let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]),
1352
+            let json = String(data: data, encoding: .utf8)
1353
+        else {
1354
+            return "{}"
1355
+        }
1356
+        return json
1357
+    }
1358
+
1359
+    nonisolated private static func hexDigest<D: Sequence>(_ bytes: D) -> String where D.Element == UInt8 {
1360
+        bytes.map { String(format: "%02x", $0) }.joined()
977 1361
     }
978 1362
 
979 1363
     func checkIntegrity() async throws -> HealthArchiveIntegrityReport {
@@ -2695,11 +3079,52 @@ private struct ArchiveV2DailyAggregateRow {
2695 3079
     }
2696 3080
 }
2697 3081
 
2698
-nonisolated private struct HealthArchiveReportPayload: Encodable {
2699
-    let reportID: UUID
2700
-    let title: String
2701
-    let exportedAt: Date
2702
-    let records: [ArchivedHealthRecord]
3082
+nonisolated private struct ExportRecordPayload: Encodable {
3083
+    let id: String
3084
+    let sampleTypeIdentifier: String
3085
+    let strictFingerprint: String
3086
+    let semanticFingerprint: String?
3087
+    let healthKitUUIDHash: String?
3088
+    let startDate: Date
3089
+    let endDate: Date
3090
+    let firstSeenAt: Date
3091
+    let lastSeenAt: Date?
3092
+    let lastVerifiedAt: Date?
3093
+    let disappearedAt: Date?
3094
+    let valueKind: String?
3095
+    let value: Double?
3096
+    let unit: String?
3097
+    let categoryValue: Int?
3098
+    let workoutActivityType: Int?
3099
+    let durationSeconds: Double?
3100
+    let sourceName: String?
3101
+    let sourceBundleIdentifier: String?
3102
+    let deviceName: String?
3103
+    let displayValue: String?
3104
+
3105
+    init(_ record: ArchivedHealthRecord) {
3106
+        id = record.id
3107
+        sampleTypeIdentifier = record.sampleTypeIdentifier
3108
+        strictFingerprint = record.strictFingerprint
3109
+        semanticFingerprint = record.semanticFingerprint
3110
+        healthKitUUIDHash = record.healthKitUUIDHash
3111
+        startDate = record.startDate
3112
+        endDate = record.endDate
3113
+        firstSeenAt = record.firstSeenAt
3114
+        lastSeenAt = record.lastSeenAt
3115
+        lastVerifiedAt = record.lastVerifiedAt
3116
+        disappearedAt = record.disappearedAt
3117
+        valueKind = record.valueKind
3118
+        value = record.value
3119
+        unit = record.unit
3120
+        categoryValue = record.categoryValue
3121
+        workoutActivityType = record.workoutActivityType
3122
+        durationSeconds = record.durationSeconds
3123
+        sourceName = record.sourceName
3124
+        sourceBundleIdentifier = record.sourceBundleIdentifier
3125
+        deviceName = record.deviceName
3126
+        displayValue = record.displayValue
3127
+    }
2703 3128
 }
2704 3129
 
2705 3130
 nonisolated private extension JSONEncoder {
@@ -2709,6 +3134,12 @@ nonisolated private extension JSONEncoder {
2709 3134
         encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
2710 3135
         return encoder
2711 3136
     }
3137
+
3138
+    static func healthArchiveDateString(_ date: Date) -> String {
3139
+        let data = try? healthArchive.encode(date)
3140
+        let encoded = data.map { String(decoding: $0, as: UTF8.self) } ?? "\"\(date.timeIntervalSince1970)\""
3141
+        return encoded.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
3142
+    }
2712 3143
 }
2713 3144
 
2714 3145
 nonisolated private func bindText(_ value: String?, to index: Int32, in statement: OpaquePointer?) {
+39 -5
HealthProbe/ViewModels/DataTypeRecordListViewModel.swift
@@ -52,6 +52,8 @@ final class DataTypeRecordListViewModel {
52 52
 
53 53
     var displayRecords: [HealthRecordValue] = []
54 54
     var isLoadingMore = false
55
+    var exportPreview: HealthArchiveExportPreview?
56
+    var exportPreviewError: String?
55 57
     var hasMore: Bool {
56 58
         if mode.archiveDiff != nil {
57 59
             return archiveHasMore && displayRecords.count < totalCount
@@ -63,6 +65,7 @@ final class DataTypeRecordListViewModel {
63 65
     private var pageIndex = 0
64 66
     private let pageSize = 50
65 67
     private let archiveStore: HealthArchiveStore
68
+    private let reportID = UUID()
66 69
     private var archiveCursor: RecordCursor?
67 70
     private var archiveHasMore = true
68 71
 
@@ -84,10 +87,21 @@ final class DataTypeRecordListViewModel {
84 87
 
85 88
     func loadFirstPage() async {
86 89
         guard displayRecords.isEmpty else { return }
90
+        await loadExportPreview()
87 91
         pageIndex = 0
88 92
         await loadNextPage()
89 93
     }
90 94
 
95
+    func loadExportPreview() async {
96
+        do {
97
+            exportPreview = try await archiveStore.exportPreview(buildExportRequest())
98
+            exportPreviewError = nil
99
+        } catch {
100
+            exportPreview = nil
101
+            exportPreviewError = error.localizedDescription
102
+        }
103
+    }
104
+
91 105
     func loadNextPage() async {
92 106
         guard !isLoadingMore, hasMore else { return }
93 107
         isLoadingMore = true
@@ -192,22 +206,42 @@ final class DataTypeRecordListViewModel {
192 206
 
193 207
     private func buildExportRequest() -> HealthArchiveReportRequest {
194 208
         switch mode {
195
-        case .disappeared(let typeID), .disappearedDiff(let typeID, _, _):
209
+        case .disappeared(let typeID):
196 210
             return HealthArchiveReportRequest(
197
-                reportID: UUID(),
211
+                reportID: reportID,
198 212
                 title: "Missing Records - \(displayName)",
199 213
                 typeIdentifierFilter: typeID,
200 214
                 disappearedOnly: true
201 215
             )
202
-        case .added(let typeID, let afterDate, let beforeDate),
203
-             .addedDiff(let typeID, let afterDate, let beforeDate, _, _):
216
+        case .disappearedDiff(let typeID, let fromObservationID, let toObservationID):
204 217
             return HealthArchiveReportRequest(
205
-                reportID: UUID(),
218
+                reportID: reportID,
219
+                title: "Missing Records - \(displayName)",
220
+                typeIdentifierFilter: typeID,
221
+                disappearedOnly: true,
222
+                diffFromObservationID: fromObservationID,
223
+                diffToObservationID: toObservationID,
224
+                diffKind: .disappeared
225
+            )
226
+        case .added(let typeID, let afterDate, let beforeDate):
227
+            return HealthArchiveReportRequest(
228
+                reportID: reportID,
206 229
                 title: "New Records - \(displayName)",
207 230
                 typeIdentifierFilter: typeID,
208 231
                 firstSeenAfter: afterDate,
209 232
                 firstSeenBefore: beforeDate
210 233
             )
234
+        case .addedDiff(let typeID, let afterDate, let beforeDate, let fromObservationID, let toObservationID):
235
+            return HealthArchiveReportRequest(
236
+                reportID: reportID,
237
+                title: "New Records - \(displayName)",
238
+                typeIdentifierFilter: typeID,
239
+                firstSeenAfter: afterDate,
240
+                firstSeenBefore: beforeDate,
241
+                diffFromObservationID: fromObservationID,
242
+                diffToObservationID: toObservationID,
243
+                diffKind: .appeared
244
+            )
211 245
         }
212 246
     }
213 247
 }
+27 -1
HealthProbe/Views/Snapshots/DataTypeRecordListView.swift
@@ -63,6 +63,27 @@ struct DataTypeRecordListView: View {
63 63
                         Text("HealthProbe stores timestamps, fingerprints, and metadata. Export includes complete record details.")
64 64
                             .font(.caption)
65 65
                             .foregroundStyle(.secondary)
66
+
67
+                        if let preview = viewModel.exportPreview {
68
+                            Divider()
69
+                            DataTypeDetailRow(label: "Export Preview") {
70
+                                Text("\(preview.estimatedRecordCount) JSON records")
71
+                                    .font(.caption)
72
+                                    .foregroundStyle(.secondary)
73
+                                    .monospacedDigit()
74
+                            }
75
+                            DataTypeDetailRow(label: "Export Kind") {
76
+                                Text(preview.exportKind)
77
+                                    .font(.caption)
78
+                                    .foregroundStyle(.secondary)
79
+                                    .lineLimit(1)
80
+                            }
81
+                        } else if let error = viewModel.exportPreviewError {
82
+                            Divider()
83
+                            Label(error, systemImage: "exclamationmark.triangle.fill")
84
+                                .font(.caption)
85
+                                .foregroundStyle(Color.warningAmber)
86
+                        }
66 87
                     }
67 88
 
68 89
                     if viewModel.displayRecords.isEmpty {
@@ -101,8 +122,13 @@ struct DataTypeRecordListView: View {
101 122
                     ToolbarItem(placement: .topBarTrailing) {
102 123
                         Menu {
103 124
                             Button(action: { Task { await viewModel.exportAllRecords() } }) {
104
-                                Label("Export as JSON", systemImage: "square.and.arrow.up")
125
+                                if viewModel.exportState == .loading {
126
+                                    Label("Exporting...", systemImage: "hourglass")
127
+                                } else {
128
+                                    Label("Export as JSON", systemImage: "square.and.arrow.up")
129
+                                }
105 130
                             }
131
+                            .disabled(viewModel.exportState == .loading)
106 132
                         } label: {
107 133
                             Image(systemName: "ellipsis.circle")
108 134
                                 .foregroundStyle(.primary)
+44 -0
HealthProbeTests/SQLiteHealthArchiveStoreTests.swift
@@ -149,6 +149,50 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
149 149
         XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
150 150
         XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
151 151
         XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
152
+
153
+        let exportRequest = HealthArchiveReportRequest(
154
+            reportID: UUID(),
155
+            title: "Disappeared Step Count Export",
156
+            typeIdentifierFilter: typeIdentifier,
157
+            diffFromObservationID: observationIDs[1],
158
+            diffToObservationID: observationIDs[2],
159
+            diffKind: .disappeared
160
+        )
161
+        let exportPreview = try await store.exportPreview(exportRequest)
162
+        let exportURL = try await store.exportReport(exportRequest)
163
+
164
+        XCTAssertEqual(exportPreview.estimatedRecordCount, 1)
165
+        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
166
+        XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 1 AND from_observation_id = \(observationIDs[1]) AND to_observation_id = \(observationIDs[2])", at: url), 1)
167
+    }
168
+
169
+    func testExportPreviewAndReportUseSQLiteManifest() async throws {
170
+        let url = databaseURL()
171
+        let store = SQLiteHealthArchiveStore(databaseURL: url)
172
+        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
173
+        let samples = [
174
+            makeStepCountSample(value: 42, start: 1_000),
175
+            makeStepCountSample(value: 7, start: 2_000)
176
+        ]
177
+
178
+        _ = try await store.upsertSamples(samples, observedAt: Date(timeIntervalSince1970: 3_000))
179
+
180
+        let request = HealthArchiveReportRequest(
181
+            reportID: UUID(),
182
+            title: "Step Count Export",
183
+            typeIdentifierFilter: typeIdentifier
184
+        )
185
+        let preview = try await store.exportPreview(request)
186
+        let exportURL = try await store.exportReport(request)
187
+
188
+        XCTAssertEqual(preview.estimatedRecordCount, 2)
189
+        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
190
+        XCTAssertEqual(try countRows(in: "export_manifests", at: url), 1)
191
+        XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 2", at: url), 1)
192
+
193
+        let cache = try CoreDataArchiveCacheStore(inMemory: true)
194
+        let rebuild = try cache.rebuild(fromArchiveAt: url)
195
+        XCTAssertEqual(rebuild.exportManifestRows, 1)
152 196
     }
153 197
 
154 198
     func testGroupedObservationKeepsPageWritesDeletesAndVerificationTogether() async throws {