import Foundation import CSQLite public enum LibraryError: Error { case open(String) case sql(String) case notFound } /// SQLite-backed project/day/shot store. All access serialized through one queue — /// safe for concurrent callers, single writer under the hood. WAL mode. public final class LibraryStore: @unchecked Sendable { private var db: OpaquePointer? private let queue = DispatchQueue(label: "forge.library") public init(path: String) throws { var handle: OpaquePointer? guard sqlite3_open(path, &handle) == SQLITE_OK, let handle else { throw LibraryError.open(String(cString: sqlite3_errmsg(handle))) } db = handle try exec("PRAGMA journal_mode=WAL") try exec("PRAGMA foreign_keys=ON") try migrate() } deinit { sqlite3_close(db) } // MARK: Core plumbing private func exec(_ sql: String) throws { var err: UnsafeMutablePointer? guard sqlite3_exec(db, sql, nil, nil, &err) == SQLITE_OK else { let msg = err.map { String(cString: $0) } ?? "unknown" sqlite3_free(err) throw LibraryError.sql(msg) } } private func prepare(_ sql: String) throws -> OpaquePointer { var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { throw LibraryError.sql(String(cString: sqlite3_errmsg(db))) } return stmt } private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) private func bind(_ stmt: OpaquePointer, _ idx: Int32, _ value: String?) { if let v = value { sqlite3_bind_text(stmt, idx, v, -1, transient) } else { sqlite3_bind_null(stmt, idx) } } private func bind(_ stmt: OpaquePointer, _ idx: Int32, _ value: Int?) { if let v = value { sqlite3_bind_int64(stmt, idx, Int64(v)) } else { sqlite3_bind_null(stmt, idx) } } private func bind(_ stmt: OpaquePointer, _ idx: Int32, _ value: Double?) { if let v = value { sqlite3_bind_double(stmt, idx, v) } else { sqlite3_bind_null(stmt, idx) } } private func bind(_ stmt: OpaquePointer, _ idx: Int32, _ value: Data?) { if let v = value { _ = v.withUnsafeBytes { buf in sqlite3_bind_blob(stmt, idx, buf.baseAddress, Int32(buf.count), transient) } } else { sqlite3_bind_null(stmt, idx) } } private func columnString(_ stmt: OpaquePointer, _ idx: Int32) -> String? { sqlite3_column_text(stmt, idx).map { String(cString: $0) } } private func columnInt(_ stmt: OpaquePointer, _ idx: Int32) -> Int? { sqlite3_column_type(stmt, idx) == SQLITE_NULL ? nil : Int(sqlite3_column_int64(stmt, idx)) } private func columnDouble(_ stmt: OpaquePointer, _ idx: Int32) -> Double? { sqlite3_column_type(stmt, idx) == SQLITE_NULL ? nil : sqlite3_column_double(stmt, idx) } private func columnData(_ stmt: OpaquePointer, _ idx: Int32) -> Data? { guard sqlite3_column_type(stmt, idx) != SQLITE_NULL, let ptr = sqlite3_column_blob(stmt, idx) else { return nil } return Data(bytes: ptr, count: Int(sqlite3_column_bytes(stmt, idx))) } private func step(_ stmt: OpaquePointer) throws { let rc = sqlite3_step(stmt) guard rc == SQLITE_DONE || rc == SQLITE_ROW else { throw LibraryError.sql(String(cString: sqlite3_errmsg(db))) } } // MARK: Migration private func migrate() throws { try exec(""" CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); """) let current = try schemaVersion(orZero: true) if current < 1 { try exec(""" CREATE TABLE projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at REAL NOT NULL ); CREATE TABLE days ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, label TEXT NOT NULL, date TEXT NOT NULL ); CREATE TABLE shots ( id TEXT PRIMARY KEY, day_id TEXT NOT NULL REFERENCES days(id) ON DELETE CASCADE, clip_name TEXT NOT NULL, camera_slot TEXT NOT NULL, tc_in TEXT, tc_out TEXT, ei INTEGER, wb INTEGER, tint INTEGER, fps REAL, grade_snapshot BLOB, still_path TEXT, created_at REAL NOT NULL, seq INTEGER ); CREATE INDEX idx_days_project ON days(project_id); CREATE INDEX idx_shots_day ON shots(day_id); DELETE FROM schema_version; INSERT INTO schema_version (version) VALUES (1); """) } } public func schemaVersion() throws -> Int { try schemaVersion(orZero: false) } private func schemaVersion(orZero: Bool) throws -> Int { let stmt = try prepare("SELECT version FROM schema_version LIMIT 1") defer { sqlite3_finalize(stmt) } if sqlite3_step(stmt) == SQLITE_ROW { return Int(sqlite3_column_int64(stmt, 0)) } if orZero { return 0 } throw LibraryError.sql("no schema version") } // MARK: Projects public func createProject(name: String) throws -> Project { try queue.sync { let p = Project(name: name) let stmt = try prepare("INSERT INTO projects (id, name, created_at) VALUES (?,?,?)") defer { sqlite3_finalize(stmt) } bind(stmt, 1, p.id.uuidString) bind(stmt, 2, p.name) bind(stmt, 3, p.createdAt.timeIntervalSince1970) try step(stmt) return p } } public func listProjects() throws -> [Project] { try queue.sync { let stmt = try prepare("SELECT id, name, created_at FROM projects ORDER BY created_at") defer { sqlite3_finalize(stmt) } var out = [Project]() while sqlite3_step(stmt) == SQLITE_ROW { guard let idStr = columnString(stmt, 0), let id = UUID(uuidString: idStr) else { continue } out.append(Project( id: id, name: columnString(stmt, 1) ?? "", createdAt: Date(timeIntervalSince1970: columnDouble(stmt, 2) ?? 0))) } return out } } public func deleteProject(id: UUID) throws { try queue.sync { let stmt = try prepare("DELETE FROM projects WHERE id = ?") defer { sqlite3_finalize(stmt) } bind(stmt, 1, id.uuidString) try step(stmt) } } // MARK: Days public func createDay(projectID: UUID, label: String, date: String) throws -> Day { try queue.sync { let d = Day(projectID: projectID, label: label, date: date) let stmt = try prepare("INSERT INTO days (id, project_id, label, date) VALUES (?,?,?,?)") defer { sqlite3_finalize(stmt) } bind(stmt, 1, d.id.uuidString) bind(stmt, 2, d.projectID.uuidString) bind(stmt, 3, d.label) bind(stmt, 4, d.date) try step(stmt) return d } } public func listDays(projectID: UUID) throws -> [Day] { try queue.sync { let stmt = try prepare("SELECT id, project_id, label, date FROM days WHERE project_id = ? ORDER BY date") defer { sqlite3_finalize(stmt) } bind(stmt, 1, projectID.uuidString) var out = [Day]() while sqlite3_step(stmt) == SQLITE_ROW { guard let idStr = columnString(stmt, 0), let id = UUID(uuidString: idStr), let pidStr = columnString(stmt, 1), let pid = UUID(uuidString: pidStr) else { continue } out.append(Day( id: id, projectID: pid, label: columnString(stmt, 2) ?? "", date: columnString(stmt, 3) ?? "")) } return out } } // MARK: Shots @discardableResult public func createShot(_ shot: Shot) throws -> Shot { try queue.sync { let stmt = try prepare(""" INSERT INTO shots (id, day_id, clip_name, camera_slot, tc_in, tc_out, ei, wb, tint, fps, grade_snapshot, still_path, created_at, seq) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?, (SELECT COALESCE(MAX(seq),0)+1 FROM shots WHERE day_id = ?)) """) defer { sqlite3_finalize(stmt) } bind(stmt, 1, shot.id.uuidString) bind(stmt, 2, shot.dayID.uuidString) bind(stmt, 3, shot.clipName) bind(stmt, 4, shot.cameraSlot) bind(stmt, 5, shot.timecodeIn) bind(stmt, 6, shot.timecodeOut) bind(stmt, 7, shot.exposureIndex) bind(stmt, 8, shot.whiteBalance) bind(stmt, 9, shot.tint) bind(stmt, 10, shot.fps) bind(stmt, 11, shot.gradeSnapshot) bind(stmt, 12, shot.stillPath) bind(stmt, 13, shot.createdAt.timeIntervalSince1970) bind(stmt, 14, shot.dayID.uuidString) try step(stmt) return shot } } public func updateShot(_ shot: Shot) throws { try queue.sync { let stmt = try prepare(""" UPDATE shots SET clip_name=?, camera_slot=?, tc_in=?, tc_out=?, ei=?, wb=?, tint=?, fps=?, grade_snapshot=?, still_path=? WHERE id=? """) defer { sqlite3_finalize(stmt) } bind(stmt, 1, shot.clipName) bind(stmt, 2, shot.cameraSlot) bind(stmt, 3, shot.timecodeIn) bind(stmt, 4, shot.timecodeOut) bind(stmt, 5, shot.exposureIndex) bind(stmt, 6, shot.whiteBalance) bind(stmt, 7, shot.tint) bind(stmt, 8, shot.fps) bind(stmt, 9, shot.gradeSnapshot) bind(stmt, 10, shot.stillPath) bind(stmt, 11, shot.id.uuidString) try step(stmt) } } public func shot(id: UUID) throws -> Shot? { try queue.sync { let stmt = try prepare("SELECT \(Self.shotColumns) FROM shots WHERE id = ?") defer { sqlite3_finalize(stmt) } bind(stmt, 1, id.uuidString) guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } return readShot(stmt) } } public func listShots(dayID: UUID) throws -> [Shot] { try queue.sync { let stmt = try prepare("SELECT \(Self.shotColumns) FROM shots WHERE day_id = ? ORDER BY seq") defer { sqlite3_finalize(stmt) } bind(stmt, 1, dayID.uuidString) var out = [Shot]() while sqlite3_step(stmt) == SQLITE_ROW { if let s = readShot(stmt) { out.append(s) } } return out } } private static let shotColumns = "id, day_id, clip_name, camera_slot, tc_in, tc_out, ei, wb, tint, fps, grade_snapshot, still_path, created_at" private func readShot(_ stmt: OpaquePointer) -> Shot? { guard let idStr = columnString(stmt, 0), let id = UUID(uuidString: idStr), let dayStr = columnString(stmt, 1), let dayID = UUID(uuidString: dayStr) else { return nil } return Shot( id: id, dayID: dayID, clipName: columnString(stmt, 2) ?? "", cameraSlot: columnString(stmt, 3) ?? "", timecodeIn: columnString(stmt, 4), timecodeOut: columnString(stmt, 5), exposureIndex: columnInt(stmt, 6), whiteBalance: columnInt(stmt, 7), tint: columnInt(stmt, 8), fps: columnDouble(stmt, 9), gradeSnapshot: columnData(stmt, 10), stillPath: columnString(stmt, 11), createdAt: Date(timeIntervalSince1970: columnDouble(stmt, 12) ?? 0)) } }