1 contributor
import CoreData
import Foundation
import SQLite3
enum CoreDataArchiveCacheStoreError: Error {
case persistentStoreLoadFailed(Error)
case openArchiveFailed(String)
case prepareFailed(String)
case stepFailed(String)
}
struct CoreDataArchiveCacheRebuildSummary: Equatable, Sendable {
let observationRows: Int
let typeSummaryRows: Int
let dailyAggregateRows: Int
let diffSummaryRows: Int
let exportManifestRows: Int
let archiveHealthRows: Int
}
struct CachedArchiveObservationRow: Equatable, Identifiable, Sendable {
let observationID: Int64
let observedAt: Date
let status: String
let triggerReason: String
let timeZoneIdentifier: String?
let trackedTypeCount: Int
let visibleRecordCount: Int
let appearedCount: Int
let disappearedCount: Int
let representationChangedCount: Int
let archiveSchemaVersion: Int
let cacheSchemaVersion: Int
let computedAt: Date
var id: Int64 { observationID }
}
struct CachedArchiveTypeSummary: Equatable, Identifiable, Sendable {
let observationID: Int64
let sampleTypeIdentifier: String
let displayName: String?
let visibleRecordCount: Int
let appearedCount: Int
let disappearedCount: Int
let representationChangedCount: Int
let earliestStartDate: Date?
let latestEndDate: Date?
let valueSum: Double?
let valueMax: Double?
let aggregateHash: String?
let computedAt: Date
var id: String { "\(observationID)|\(sampleTypeIdentifier)" }
}
struct CachedArchiveDiffSummary: Equatable, Identifiable, Sendable {
let fromObservationID: Int64
let toObservationID: Int64
let sampleTypeIdentifier: String?
let appearedCount: Int
let disappearedCount: Int
let representationChangedCount: Int
let consolidationLikely: Bool
let uncertaintyReason: String?
let computedAt: Date
var id: String {
"\(fromObservationID)|\(toObservationID)|\(sampleTypeIdentifier ?? "*")"
}
}
struct CachedArchiveHealthStatus: Equatable, Sendable {
let archiveSchemaVersion: Int
let cacheSchemaVersion: Int
let lastIntegrityCheckAt: Date
let lastIntegrityStatus: String
let lastErrorKind: String?
let lastErrorMessageHash: String?
let cacheBuildID: String
let computedAt: Date
}
// Interface updated 2026-05-24 — see AGENTS.md
nonisolated final class CoreDataArchiveCacheStore {
static let cacheSchemaVersion = 1
let container: NSPersistentContainer
init(storeURL: URL? = nil, inMemory: Bool = false) throws {
let model = Self.makeModel()
container = NSPersistentContainer(name: "HealthProbeCache", managedObjectModel: model)
let description = NSPersistentStoreDescription()
if inMemory {
description.type = NSInMemoryStoreType
} else {
let resolvedURL = storeURL ?? URL.applicationSupportDirectory.appending(path: "HealthProbeCache.sqlite")
description.url = resolvedURL
}
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
container.persistentStoreDescriptions = [description]
var loadError: Error?
container.loadPersistentStores { _, error in
loadError = error
}
if let loadError {
throw CoreDataArchiveCacheStoreError.persistentStoreLoadFailed(loadError)
}
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
func rebuild(fromArchiveAt archiveURL: URL) throws -> CoreDataArchiveCacheRebuildSummary {
let archive = try openArchive(at: archiveURL)
defer { sqlite3_close(archive) }
let archiveSchemaVersion = try archiveSchemaVersion(db: archive)
let integrityStatus = try firstText("PRAGMA integrity_check", db: archive) ?? "missing"
let computedAt = Date()
let context = container.viewContext
try resetCache(context: context)
let observationCount = try insertObservationRows(db: archive, context: context, archiveSchemaVersion: archiveSchemaVersion, computedAt: computedAt)
let typeSummaryCount = try insertTypeSummaryRows(db: archive, context: context, computedAt: computedAt)
let dailyAggregateCount = try insertDailyAggregateRows(db: archive, context: context, computedAt: computedAt)
let diffSummaryCount = try insertDiffSummaryRows(db: archive, context: context, computedAt: computedAt)
let exportManifestCount = try insertExportManifestRows(db: archive, context: context, computedAt: computedAt)
try insertArchiveHealthRow(
context: context,
archiveSchemaVersion: archiveSchemaVersion,
integrityStatus: integrityStatus,
computedAt: computedAt
)
if context.hasChanges {
try context.save()
}
return CoreDataArchiveCacheRebuildSummary(
observationRows: observationCount,
typeSummaryRows: typeSummaryCount,
dailyAggregateRows: dailyAggregateCount,
diffSummaryRows: diffSummaryCount,
exportManifestRows: exportManifestCount,
archiveHealthRows: 1
)
}
func deleteCache() throws {
let context = container.viewContext
try resetCache(context: context)
if context.hasChanges {
try context.save()
}
}
func observationRows(limit: Int = 50) throws -> [CachedArchiveObservationRow] {
let request = NSFetchRequest<NSManagedObject>(entityName: "CachedObservationRow")
request.sortDescriptors = [NSSortDescriptor(key: "observationID", ascending: false)]
request.fetchLimit = max(limit, 0)
return try container.viewContext.fetch(request).map(Self.observationRow)
}
func observationCount() throws -> Int {
let request = NSFetchRequest<NSManagedObject>(entityName: "CachedObservationRow")
return try container.viewContext.count(for: request)
}
func latestObservationRow() throws -> CachedArchiveObservationRow? {
try observationRows(limit: 1).first
}
func typeSummaries(observationID: Int64, limit: Int? = nil) throws -> [CachedArchiveTypeSummary] {
let request = NSFetchRequest<NSManagedObject>(entityName: "CachedTypeSummary")
request.predicate = NSPredicate(format: "observationID == %lld", observationID)
request.sortDescriptors = [
NSSortDescriptor(key: "displayName", ascending: true),
NSSortDescriptor(key: "sampleTypeIdentifier", ascending: true)
]
if let limit {
request.fetchLimit = max(limit, 0)
}
return try container.viewContext.fetch(request).map(Self.typeSummary)
}
func diffSummary(
fromObservationID: Int64,
toObservationID: Int64,
sampleTypeIdentifier: String?
) throws -> CachedArchiveDiffSummary? {
let request = NSFetchRequest<NSManagedObject>(entityName: "CachedDiffSummary")
if let sampleTypeIdentifier {
request.predicate = NSPredicate(
format: "fromObservationID == %lld AND toObservationID == %lld AND sampleTypeIdentifier == %@",
fromObservationID,
toObservationID,
sampleTypeIdentifier
)
} else {
request.predicate = NSPredicate(
format: "fromObservationID == %lld AND toObservationID == %lld AND sampleTypeIdentifier == nil",
fromObservationID,
toObservationID
)
}
request.fetchLimit = 1
return try container.viewContext.fetch(request).first.map(Self.diffSummary)
}
func latestArchiveHealthStatus() throws -> CachedArchiveHealthStatus? {
let request = NSFetchRequest<NSManagedObject>(entityName: "CachedArchiveHealth")
request.sortDescriptors = [NSSortDescriptor(key: "computedAt", ascending: false)]
request.fetchLimit = 1
return try container.viewContext.fetch(request).first.map(Self.archiveHealthStatus)
}
private func resetCache(context: NSManagedObjectContext) throws {
for entityName in Self.cacheEntityNames {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
let objects = try context.fetch(request)
for object in objects {
context.delete(object)
}
}
}
private func insertObservationRows(
db: OpaquePointer?,
context: NSManagedObjectContext,
archiveSchemaVersion: Int,
computedAt: Date
) throws -> Int {
let sql = """
SELECT
o.id,
o.observed_at,
o.status,
o.trigger_reason,
o.time_zone_identifier,
COUNT(s.sample_type_id),
COALESCE(SUM(s.visible_record_count), 0),
COALESCE(SUM(s.appeared_count), 0),
COALESCE(SUM(s.disappeared_count), 0),
COALESCE(SUM(s.representation_changed_count), 0),
COALESCE(GROUP_CONCAT(s.aggregate_hash, '|'), '')
FROM observations o
LEFT JOIN observation_type_summaries s ON s.observation_id = o.id
GROUP BY o.id, o.observed_at, o.status, o.trigger_reason, o.time_zone_identifier
ORDER BY o.id
"""
return try withStatement(sql, db: db) { statement in
var count = 0
while sqlite3_step(statement) == SQLITE_ROW {
let observationID = sqlite3_column_int64(statement, 0)
let sourceHash = HashService.archiveContentHash(
domain: "hp:cache:observation_row",
parts: [
String(observationID),
columnText(statement, 10)
]
)
let object = NSEntityDescription.insertNewObject(forEntityName: "CachedObservationRow", into: context)
object.setValue(observationID, forKey: "observationID")
object.setValue(columnUnixDate(statement, 1), forKey: "observedAt")
object.setValue(columnText(statement, 2) ?? "unknown", forKey: "status")
object.setValue(columnText(statement, 3) ?? "unknown", forKey: "triggerReason")
object.setValue(columnText(statement, 4), forKey: "timeZoneIdentifier")
object.setValue(Int64(sqlite3_column_int64(statement, 5)), forKey: "trackedTypeCount")
object.setValue(Int64(sqlite3_column_int64(statement, 6)), forKey: "visibleRecordCount")
object.setValue(Int64(sqlite3_column_int64(statement, 7)), forKey: "appearedCount")
object.setValue(Int64(sqlite3_column_int64(statement, 8)), forKey: "disappearedCount")
object.setValue(Int64(sqlite3_column_int64(statement, 9)), forKey: "representationChangedCount")
object.setValue(Int64(archiveSchemaVersion), forKey: "archiveSchemaVersion")
object.setValue(Int64(Self.cacheSchemaVersion), forKey: "cacheSchemaVersion")
object.setValue(sourceHash, forKey: "sourceAggregateHash")
object.setValue(computedAt, forKey: "computedAt")
count += 1
}
return count
}
}
private func insertTypeSummaryRows(
db: OpaquePointer?,
context: NSManagedObjectContext,
computedAt: Date
) throws -> Int {
let sql = """
SELECT
s.observation_id,
t.type_identifier,
t.display_name,
s.visible_record_count,
s.appeared_count,
s.disappeared_count,
s.representation_changed_count,
s.earliest_start_date,
s.latest_end_date,
s.value_sum,
s.value_max,
s.aggregate_hash
FROM observation_type_summaries s
JOIN sample_types t ON t.id = s.sample_type_id
ORDER BY s.observation_id, t.type_identifier
"""
return try withStatement(sql, db: db) { statement in
var count = 0
while sqlite3_step(statement) == SQLITE_ROW {
let object = NSEntityDescription.insertNewObject(forEntityName: "CachedTypeSummary", into: context)
object.setValue(sqlite3_column_int64(statement, 0), forKey: "observationID")
object.setValue(columnText(statement, 1) ?? "", forKey: "sampleTypeIdentifier")
object.setValue(columnText(statement, 2), forKey: "displayName")
object.setValue(Int64(sqlite3_column_int64(statement, 3)), forKey: "visibleRecordCount")
object.setValue(Int64(sqlite3_column_int64(statement, 4)), forKey: "appearedCount")
object.setValue(Int64(sqlite3_column_int64(statement, 5)), forKey: "disappearedCount")
object.setValue(Int64(sqlite3_column_int64(statement, 6)), forKey: "representationChangedCount")
object.setValue(columnUnixDate(statement, 7), forKey: "earliestStartDate")
object.setValue(columnUnixDate(statement, 8), forKey: "latestEndDate")
object.setValue(columnDouble(statement, 9), forKey: "valueSum")
object.setValue(columnDouble(statement, 10), forKey: "valueMax")
object.setValue(columnText(statement, 11), forKey: "aggregateHash")
object.setValue(computedAt, forKey: "computedAt")
count += 1
}
return count
}
}
private func insertDailyAggregateRows(
db: OpaquePointer?,
context: NSManagedObjectContext,
computedAt: Date
) throws -> Int {
let sql = """
SELECT
d.observation_id,
t.type_identifier,
d.bucket_start,
d.bucket_end,
o.time_zone_identifier,
d.visible_record_count,
d.value_sum,
d.value_max,
sr.id,
d.aggregate_hash
FROM daily_type_aggregates d
JOIN sample_types t ON t.id = d.sample_type_id
JOIN observations o ON o.id = d.observation_id
LEFT JOIN source_revisions sr ON sr.id = d.source_revision_id
ORDER BY d.observation_id, t.type_identifier, d.bucket_start, sr.id
"""
return try withStatement(sql, db: db) { statement in
var count = 0
while sqlite3_step(statement) == SQLITE_ROW {
let sourceRevisionHash = columnInt64(statement, 8).map {
HashService.archiveContentHash(domain: "hp:cache:source_revision", parts: [String($0)])
}
let object = NSEntityDescription.insertNewObject(forEntityName: "CachedDailyAggregate", into: context)
object.setValue(sqlite3_column_int64(statement, 0), forKey: "observationID")
object.setValue(columnText(statement, 1) ?? "", forKey: "sampleTypeIdentifier")
object.setValue(columnUnixDate(statement, 2), forKey: "bucketStart")
object.setValue(columnUnixDate(statement, 3), forKey: "bucketEnd")
object.setValue(columnText(statement, 4), forKey: "timeZoneIdentifier")
object.setValue(Int64(sqlite3_column_int64(statement, 5)), forKey: "visibleRecordCount")
object.setValue(columnDouble(statement, 6), forKey: "valueSum")
object.setValue(columnDouble(statement, 7), forKey: "valueMax")
object.setValue(sourceRevisionHash, forKey: "sourceRevisionDisplayHash")
object.setValue(columnText(statement, 9), forKey: "aggregateHash")
object.setValue(computedAt, forKey: "computedAt")
count += 1
}
return count
}
}
private func insertDiffSummaryRows(
db: OpaquePointer?,
context: NSManagedObjectContext,
computedAt: Date
) throws -> Int {
let sql = """
WITH pairs AS (
SELECT
o.id AS to_id,
(SELECT MAX(p.id) FROM observations p WHERE p.id < o.id) AS from_id
FROM observations o
)
SELECT
pairs.from_id,
pairs.to_id,
t.type_identifier,
SUM(CASE WHEN e.event_kind = 'appeared' THEN 1 ELSE 0 END),
SUM(CASE WHEN e.event_kind = 'disappeared' THEN 1 ELSE 0 END),
SUM(CASE WHEN e.event_kind = 'representationChanged' THEN 1 ELSE 0 END)
FROM pairs
JOIN sample_observation_events e ON e.observation_id = pairs.to_id
JOIN samples s ON s.id = e.sample_id
JOIN sample_types t ON t.id = s.sample_type_id
WHERE pairs.from_id IS NOT NULL
GROUP BY pairs.from_id, pairs.to_id, t.type_identifier
ORDER BY pairs.from_id, pairs.to_id, t.type_identifier
"""
return try withStatement(sql, db: db) { statement in
var count = 0
while sqlite3_step(statement) == SQLITE_ROW {
let fromObservationID = sqlite3_column_int64(statement, 0)
let toObservationID = sqlite3_column_int64(statement, 1)
let sampleTypeIdentifier = columnText(statement, 2) ?? ""
let appearedCount = Int64(sqlite3_column_int64(statement, 3))
let disappearedCount = Int64(sqlite3_column_int64(statement, 4))
let representationChangedCount = Int64(sqlite3_column_int64(statement, 5))
let sourceHash = HashService.archiveContentHash(
domain: "hp:cache:diff_summary",
parts: [
String(fromObservationID),
String(toObservationID),
sampleTypeIdentifier,
String(appearedCount),
String(disappearedCount),
String(representationChangedCount)
]
)
let object = NSEntityDescription.insertNewObject(forEntityName: "CachedDiffSummary", into: context)
object.setValue(fromObservationID, forKey: "fromObservationID")
object.setValue(toObservationID, forKey: "toObservationID")
object.setValue(sampleTypeIdentifier, forKey: "sampleTypeIdentifier")
object.setValue(appearedCount, forKey: "appearedCount")
object.setValue(disappearedCount, forKey: "disappearedCount")
object.setValue(representationChangedCount, forKey: "representationChangedCount")
object.setValue(false, forKey: "consolidationLikely")
object.setValue(nil, forKey: "uncertaintyReason")
object.setValue(sourceHash, forKey: "sourceAggregateHash")
object.setValue(computedAt, forKey: "computedAt")
count += 1
}
return count
}
}
private func insertExportManifestRows(
db: OpaquePointer?,
context: NSManagedObjectContext,
computedAt: Date
) throws -> Int {
let sql = """
SELECT
export_id,
export_kind,
created_at,
from_observation_id,
to_observation_id,
filter_json,
record_count,
manifest_hash
FROM export_manifests
ORDER BY created_at, id
"""
return try withStatement(sql, db: db) { statement in
var count = 0
while sqlite3_step(statement) == SQLITE_ROW {
let object = NSEntityDescription.insertNewObject(forEntityName: "CachedExportManifest", into: context)
object.setValue(columnText(statement, 0) ?? "", forKey: "exportID")
object.setValue(columnText(statement, 1) ?? "unknown", forKey: "exportKind")
object.setValue(columnUnixDate(statement, 2), forKey: "createdAt")
object.setValue(columnInt64(statement, 3), forKey: "fromObservationID")
object.setValue(columnInt64(statement, 4), forKey: "toObservationID")
object.setValue(columnText(statement, 5), forKey: "filterSummary")
object.setValue(Int64(sqlite3_column_int64(statement, 6)), forKey: "recordCount")
object.setValue(columnText(statement, 7) ?? "", forKey: "manifestHash")
object.setValue(nil, forKey: "fileURLBookmarkData")
object.setValue("available", forKey: "status")
object.setValue(computedAt, forKey: "computedAt")
count += 1
}
return count
}
}
private func insertArchiveHealthRow(
context: NSManagedObjectContext,
archiveSchemaVersion: Int,
integrityStatus: String,
computedAt: Date
) throws {
let object = NSEntityDescription.insertNewObject(forEntityName: "CachedArchiveHealth", into: context)
object.setValue(Int64(archiveSchemaVersion), forKey: "archiveSchemaVersion")
object.setValue(Int64(Self.cacheSchemaVersion), forKey: "cacheSchemaVersion")
object.setValue(computedAt, forKey: "lastIntegrityCheckAt")
object.setValue(integrityStatus, forKey: "lastIntegrityStatus")
object.setValue(nil, forKey: "lastErrorKind")
object.setValue(nil, forKey: "lastErrorMessageHash")
object.setValue(UUID().uuidString, forKey: "cacheBuildID")
object.setValue(computedAt, forKey: "computedAt")
}
}
nonisolated private extension CoreDataArchiveCacheStore {
nonisolated static func observationRow(_ object: NSManagedObject) -> CachedArchiveObservationRow {
CachedArchiveObservationRow(
observationID: object.value(forKey: "observationID") as? Int64 ?? 0,
observedAt: object.value(forKey: "observedAt") as? Date ?? Date(timeIntervalSince1970: 0),
status: object.value(forKey: "status") as? String ?? "unknown",
triggerReason: object.value(forKey: "triggerReason") as? String ?? "unknown",
timeZoneIdentifier: object.value(forKey: "timeZoneIdentifier") as? String,
trackedTypeCount: Int(object.value(forKey: "trackedTypeCount") as? Int64 ?? 0),
visibleRecordCount: Int(object.value(forKey: "visibleRecordCount") as? Int64 ?? 0),
appearedCount: Int(object.value(forKey: "appearedCount") as? Int64 ?? 0),
disappearedCount: Int(object.value(forKey: "disappearedCount") as? Int64 ?? 0),
representationChangedCount: Int(object.value(forKey: "representationChangedCount") as? Int64 ?? 0),
archiveSchemaVersion: Int(object.value(forKey: "archiveSchemaVersion") as? Int64 ?? 0),
cacheSchemaVersion: Int(object.value(forKey: "cacheSchemaVersion") as? Int64 ?? 0),
computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0)
)
}
nonisolated static func typeSummary(_ object: NSManagedObject) -> CachedArchiveTypeSummary {
CachedArchiveTypeSummary(
observationID: object.value(forKey: "observationID") as? Int64 ?? 0,
sampleTypeIdentifier: object.value(forKey: "sampleTypeIdentifier") as? String ?? "",
displayName: object.value(forKey: "displayName") as? String,
visibleRecordCount: Int(object.value(forKey: "visibleRecordCount") as? Int64 ?? 0),
appearedCount: Int(object.value(forKey: "appearedCount") as? Int64 ?? 0),
disappearedCount: Int(object.value(forKey: "disappearedCount") as? Int64 ?? 0),
representationChangedCount: Int(object.value(forKey: "representationChangedCount") as? Int64 ?? 0),
earliestStartDate: object.value(forKey: "earliestStartDate") as? Date,
latestEndDate: object.value(forKey: "latestEndDate") as? Date,
valueSum: object.value(forKey: "valueSum") as? Double,
valueMax: object.value(forKey: "valueMax") as? Double,
aggregateHash: object.value(forKey: "aggregateHash") as? String,
computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0)
)
}
nonisolated static func diffSummary(_ object: NSManagedObject) -> CachedArchiveDiffSummary {
CachedArchiveDiffSummary(
fromObservationID: object.value(forKey: "fromObservationID") as? Int64 ?? 0,
toObservationID: object.value(forKey: "toObservationID") as? Int64 ?? 0,
sampleTypeIdentifier: object.value(forKey: "sampleTypeIdentifier") as? String,
appearedCount: Int(object.value(forKey: "appearedCount") as? Int64 ?? 0),
disappearedCount: Int(object.value(forKey: "disappearedCount") as? Int64 ?? 0),
representationChangedCount: Int(object.value(forKey: "representationChangedCount") as? Int64 ?? 0),
consolidationLikely: object.value(forKey: "consolidationLikely") as? Bool ?? false,
uncertaintyReason: object.value(forKey: "uncertaintyReason") as? String,
computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0)
)
}
nonisolated static func archiveHealthStatus(_ object: NSManagedObject) -> CachedArchiveHealthStatus {
CachedArchiveHealthStatus(
archiveSchemaVersion: Int(object.value(forKey: "archiveSchemaVersion") as? Int64 ?? 0),
cacheSchemaVersion: Int(object.value(forKey: "cacheSchemaVersion") as? Int64 ?? 0),
lastIntegrityCheckAt: object.value(forKey: "lastIntegrityCheckAt") as? Date ?? Date(timeIntervalSince1970: 0),
lastIntegrityStatus: object.value(forKey: "lastIntegrityStatus") as? String ?? "unknown",
lastErrorKind: object.value(forKey: "lastErrorKind") as? String,
lastErrorMessageHash: object.value(forKey: "lastErrorMessageHash") as? String,
cacheBuildID: object.value(forKey: "cacheBuildID") as? String ?? "",
computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0)
)
}
}
nonisolated private extension CoreDataArchiveCacheStore {
static let cacheEntityNames = [
"CachedObservationRow",
"CachedTypeSummary",
"CachedDailyAggregate",
"CachedDiffSummary",
"CachedExportManifest",
"CachedArchiveHealth"
]
static func makeModel() -> NSManagedObjectModel {
let model = NSManagedObjectModel()
model.entities = [
cachedObservationRowEntity(),
cachedTypeSummaryEntity(),
cachedDailyAggregateEntity(),
cachedDiffSummaryEntity(),
cachedExportManifestEntity(),
cachedArchiveHealthEntity()
]
return model
}
static func cachedObservationRowEntity() -> NSEntityDescription {
entity("CachedObservationRow", attributes: [
attribute("observationID", .integer64AttributeType),
attribute("observedAt", .dateAttributeType),
attribute("status", .stringAttributeType),
attribute("triggerReason", .stringAttributeType),
attribute("timeZoneIdentifier", .stringAttributeType, optional: true),
attribute("trackedTypeCount", .integer64AttributeType),
attribute("visibleRecordCount", .integer64AttributeType),
attribute("appearedCount", .integer64AttributeType),
attribute("disappearedCount", .integer64AttributeType),
attribute("representationChangedCount", .integer64AttributeType),
attribute("archiveSchemaVersion", .integer64AttributeType),
attribute("cacheSchemaVersion", .integer64AttributeType),
attribute("sourceAggregateHash", .stringAttributeType),
attribute("computedAt", .dateAttributeType)
])
}
static func cachedTypeSummaryEntity() -> NSEntityDescription {
entity("CachedTypeSummary", attributes: [
attribute("observationID", .integer64AttributeType),
attribute("sampleTypeIdentifier", .stringAttributeType),
attribute("displayName", .stringAttributeType, optional: true),
attribute("visibleRecordCount", .integer64AttributeType),
attribute("appearedCount", .integer64AttributeType),
attribute("disappearedCount", .integer64AttributeType),
attribute("representationChangedCount", .integer64AttributeType),
attribute("earliestStartDate", .dateAttributeType, optional: true),
attribute("latestEndDate", .dateAttributeType, optional: true),
attribute("valueSum", .doubleAttributeType, optional: true),
attribute("valueMax", .doubleAttributeType, optional: true),
attribute("aggregateHash", .stringAttributeType, optional: true),
attribute("computedAt", .dateAttributeType)
])
}
static func cachedDailyAggregateEntity() -> NSEntityDescription {
entity("CachedDailyAggregate", attributes: [
attribute("observationID", .integer64AttributeType),
attribute("sampleTypeIdentifier", .stringAttributeType),
attribute("bucketStart", .dateAttributeType),
attribute("bucketEnd", .dateAttributeType),
attribute("timeZoneIdentifier", .stringAttributeType, optional: true),
attribute("visibleRecordCount", .integer64AttributeType),
attribute("valueSum", .doubleAttributeType, optional: true),
attribute("valueMax", .doubleAttributeType, optional: true),
attribute("sourceRevisionDisplayHash", .stringAttributeType, optional: true),
attribute("aggregateHash", .stringAttributeType, optional: true),
attribute("computedAt", .dateAttributeType)
])
}
static func cachedDiffSummaryEntity() -> NSEntityDescription {
entity("CachedDiffSummary", attributes: [
attribute("fromObservationID", .integer64AttributeType),
attribute("toObservationID", .integer64AttributeType),
attribute("sampleTypeIdentifier", .stringAttributeType, optional: true),
attribute("appearedCount", .integer64AttributeType),
attribute("disappearedCount", .integer64AttributeType),
attribute("representationChangedCount", .integer64AttributeType),
attribute("consolidationLikely", .booleanAttributeType),
attribute("uncertaintyReason", .stringAttributeType, optional: true),
attribute("sourceAggregateHash", .stringAttributeType),
attribute("computedAt", .dateAttributeType)
])
}
static func cachedExportManifestEntity() -> NSEntityDescription {
entity("CachedExportManifest", attributes: [
attribute("exportID", .stringAttributeType),
attribute("exportKind", .stringAttributeType),
attribute("createdAt", .dateAttributeType),
attribute("fromObservationID", .integer64AttributeType, optional: true),
attribute("toObservationID", .integer64AttributeType, optional: true),
attribute("filterSummary", .stringAttributeType, optional: true),
attribute("recordCount", .integer64AttributeType),
attribute("manifestHash", .stringAttributeType),
attribute("fileURLBookmarkData", .binaryDataAttributeType, optional: true),
attribute("status", .stringAttributeType),
attribute("computedAt", .dateAttributeType)
])
}
static func cachedArchiveHealthEntity() -> NSEntityDescription {
entity("CachedArchiveHealth", attributes: [
attribute("archiveSchemaVersion", .integer64AttributeType),
attribute("cacheSchemaVersion", .integer64AttributeType),
attribute("lastIntegrityCheckAt", .dateAttributeType),
attribute("lastIntegrityStatus", .stringAttributeType),
attribute("lastErrorKind", .stringAttributeType, optional: true),
attribute("lastErrorMessageHash", .stringAttributeType, optional: true),
attribute("cacheBuildID", .stringAttributeType),
attribute("computedAt", .dateAttributeType)
])
}
static func entity(_ name: String, attributes: [NSAttributeDescription]) -> NSEntityDescription {
let entity = NSEntityDescription()
entity.name = name
entity.managedObjectClassName = "NSManagedObject"
entity.properties = attributes
return entity
}
static func attribute(
_ name: String,
_ type: NSAttributeType,
optional: Bool = false
) -> NSAttributeDescription {
let attribute = NSAttributeDescription()
attribute.name = name
attribute.attributeType = type
attribute.isOptional = optional
return attribute
}
}
nonisolated private extension CoreDataArchiveCacheStore {
func openArchive(at archiveURL: URL) throws -> OpaquePointer? {
var db: OpaquePointer?
guard sqlite3_open_v2(archiveURL.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
let message = db.map(lastErrorMessage) ?? "unable to open archive database"
sqlite3_close(db)
throw CoreDataArchiveCacheStoreError.openArchiveFailed(message)
}
return db
}
func archiveSchemaVersion(db: OpaquePointer?) throws -> Int {
let sql = "SELECT value FROM archive_metadata WHERE key = 'schema_version' LIMIT 1"
return try withStatement(sql, db: db) { statement in
guard sqlite3_step(statement) == SQLITE_ROW,
let value = columnText(statement, 0),
let version = Int(value) else {
return 0
}
return version
}
}
func firstText(_ sql: String, db: OpaquePointer?) throws -> String? {
try withStatement(sql, db: db) { statement in
guard sqlite3_step(statement) == SQLITE_ROW else {
return nil
}
return columnText(statement, 0)
}
}
func withStatement<T>(_ sql: String, db: OpaquePointer?, body: (OpaquePointer?) throws -> T) throws -> T {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
throw CoreDataArchiveCacheStoreError.prepareFailed(lastErrorMessage(db))
}
defer { sqlite3_finalize(statement) }
return try body(statement)
}
}
nonisolated private func lastErrorMessage(_ db: OpaquePointer?) -> String {
guard let message = sqlite3_errmsg(db) else { return "unknown SQLite error" }
return String(cString: message)
}
nonisolated private func columnText(_ statement: OpaquePointer?, _ index: Int32) -> String? {
guard sqlite3_column_type(statement, index) != SQLITE_NULL,
let pointer = sqlite3_column_text(statement, index) else {
return nil
}
return String(cString: pointer)
}
nonisolated private func columnDouble(_ statement: OpaquePointer?, _ index: Int32) -> Double? {
guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil }
return sqlite3_column_double(statement, index)
}
nonisolated private func columnInt64(_ statement: OpaquePointer?, _ index: Int32) -> Int64? {
guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil }
return sqlite3_column_int64(statement, index)
}
nonisolated private func columnUnixDate(_ statement: OpaquePointer?, _ index: Int32) -> Date? {
columnDouble(statement, index).map { Date(timeIntervalSince1970: $0) }
}