rainbow-dragon/Sources/ForgeLibrary/LibraryStore.swift

342 lines
12 KiB
Swift

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<CChar>?
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))
}
}