/// SQLite persistence via GRDB. /// Schema: Session -> Measurement -> DataPoint import Foundation import GRDB // MARK: - Records struct Session: Codable, FetchableRecord, MutablePersistableRecord { var id: Int64? var startedAt: Date var label: String? var notes: String? mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } } struct Measurement: Codable, FetchableRecord, MutablePersistableRecord { var id: Int64? var sessionId: Int64 var type: String var startedAt: Date var config: Data? var resultSummary: Data? mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } } struct DataPoint: Codable, FetchableRecord, MutablePersistableRecord { var id: Int64? var measurementId: Int64 var index: Int var payload: Data mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } } // MARK: - Database manager final class Storage { static let shared = Storage() private let dbQueue: DatabaseQueue private init() { let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let path = dir.appendingPathComponent("eis4.sqlite").path do { dbQueue = try DatabaseQueue(path: path) try migrate() } catch { fatalError("Database init failed: \(error)") } } private func migrate() throws { var migrator = DatabaseMigrator() migrator.registerMigration("v1") { db in try db.create(table: "session") { t in t.autoIncrementedPrimaryKey("id") t.column("startedAt", .datetime).notNull() t.column("label", .text) t.column("notes", .text) } try db.create(table: "measurement") { t in t.autoIncrementedPrimaryKey("id") t.column("sessionId", .integer).notNull() .references("session", onDelete: .cascade) t.column("type", .text).notNull() t.column("startedAt", .datetime).notNull() t.column("config", .blob) t.column("resultSummary", .blob) } try db.create(table: "dataPoint") { t in t.autoIncrementedPrimaryKey("id") t.column("measurementId", .integer).notNull() .references("measurement", onDelete: .cascade) t.column("index", .integer).notNull() t.column("payload", .blob).notNull() } } try migrator.migrate(dbQueue) } // MARK: - Sessions func createSession(label: String? = nil) throws -> Session { try dbQueue.write { db in var s = Session(startedAt: Date(), label: label) try s.insert(db) return s } } func fetchSessions() throws -> [Session] { try dbQueue.read { db in try Session.order(Column("startedAt").desc).fetchAll(db) } } func deleteSession(_ id: Int64) throws { try dbQueue.write { db in try db.execute(sql: "DELETE FROM session WHERE id = ?", arguments: [id]) } } func updateSession(_ id: Int64, label: String?, notes: String?) throws { try dbQueue.write { db in try db.execute( sql: "UPDATE session SET label = ?, notes = ? WHERE id = ?", arguments: [label, notes, id] ) } } // MARK: - Measurements func addMeasurement( sessionId: Int64, type: MeasurementType, config: (any Encodable)? = nil ) throws -> Measurement { let configData: Data? = if let config { try JSONEncoder().encode(config) } else { nil } return try dbQueue.write { db in var m = Measurement( sessionId: sessionId, type: type.rawValue, startedAt: Date(), config: configData ) try m.insert(db) return m } } func setMeasurementResult(_ measurementId: Int64, result: any Encodable) throws { try dbQueue.write { db in let data = try JSONEncoder().encode(result) try db.execute( sql: "UPDATE measurement SET resultSummary = ? WHERE id = ?", arguments: [data, measurementId] ) } } func fetchMeasurements(sessionId: Int64) throws -> [Measurement] { try dbQueue.read { db in try Measurement .filter(Column("sessionId") == sessionId) .order(Column("startedAt").asc) .fetchAll(db) } } func deleteMeasurement(_ id: Int64) throws { try dbQueue.write { db in try db.execute(sql: "DELETE FROM measurement WHERE id = ?", arguments: [id]) } } func dataPointCount(measurementId: Int64) throws -> Int { try dbQueue.read { db in try DataPoint .filter(Column("measurementId") == measurementId) .fetchCount(db) } } func measurementCount(sessionId: Int64) throws -> Int { try dbQueue.read { db in try Measurement .filter(Column("sessionId") == sessionId) .fetchCount(db) } } // MARK: - Data points func addDataPoint(measurementId: Int64, index: Int, point: T) throws { let payload = try JSONEncoder().encode(point) try dbQueue.write { db in var dp = DataPoint( measurementId: measurementId, index: index, payload: payload ) try dp.insert(db) } } func addDataPoints(measurementId: Int64, points: [(index: Int, value: T)]) throws { let encoder = JSONEncoder() try dbQueue.write { db in for (idx, val) in points { let payload = try encoder.encode(val) var dp = DataPoint(measurementId: measurementId, index: idx, payload: payload) try dp.insert(db) } } } func fetchDataPoints(measurementId: Int64) throws -> [DataPoint] { try dbQueue.read { db in try DataPoint .filter(Column("measurementId") == measurementId) .order(Column("index").asc) .fetchAll(db) } } /// Decode stored data points into typed values. func fetchTypedPoints(measurementId: Int64, as type: T.Type) throws -> [T] { let rows = try fetchDataPoints(measurementId: measurementId) let decoder = JSONDecoder() return try rows.map { try decoder.decode(T.self, from: $0.payload) } } // MARK: - Observation (for SwiftUI live updates) func observeDataPoints( measurementId: Int64, onChange: @escaping ([DataPoint]) -> Void ) -> DatabaseCancellable { let observation = ValueObservation.tracking { db in try DataPoint .filter(Column("measurementId") == measurementId) .order(Column("index").asc) .fetchAll(db) } return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange) } func observeSessions(onChange: @escaping ([Session]) -> Void) -> DatabaseCancellable { let observation = ValueObservation.tracking { db in try Session.order(Column("startedAt").desc).fetchAll(db) } return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange) } }