From f723ea61773804bebff2525594a72e6088b73f74 Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 19:37:26 +0000 Subject: [PATCH] =?UTF-8?q?library:=20SQLite=20store=20=E2=80=94=20project?= =?UTF-8?q?/day/shot=20CRUD,=20grade=20snapshot=20blobs,=20WAL,=20cascade?= =?UTF-8?q?=20delete,=20serialized=20writes=20=E2=80=94=208=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeLibrary/ForgeLibrary.swift | 1 - Sources/ForgeLibrary/LibraryStore.swift | 342 ++++++++++++++++++ Sources/ForgeLibrary/Models.swift | 76 ++++ .../ForgeLibraryTests/LibraryStoreTests.swift | 129 +++++++ .../ForgeLibraryTests/PlaceholderTests.swift | 5 - 5 files changed, 547 insertions(+), 6 deletions(-) delete mode 100644 Sources/ForgeLibrary/ForgeLibrary.swift create mode 100644 Sources/ForgeLibrary/LibraryStore.swift create mode 100644 Sources/ForgeLibrary/Models.swift create mode 100644 Tests/ForgeLibraryTests/LibraryStoreTests.swift delete mode 100644 Tests/ForgeLibraryTests/PlaceholderTests.swift diff --git a/Sources/ForgeLibrary/ForgeLibrary.swift b/Sources/ForgeLibrary/ForgeLibrary.swift deleted file mode 100644 index 91cd49c..0000000 --- a/Sources/ForgeLibrary/ForgeLibrary.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeLibrary diff --git a/Sources/ForgeLibrary/LibraryStore.swift b/Sources/ForgeLibrary/LibraryStore.swift new file mode 100644 index 0000000..63f4946 --- /dev/null +++ b/Sources/ForgeLibrary/LibraryStore.swift @@ -0,0 +1,342 @@ +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)) + } +} diff --git a/Sources/ForgeLibrary/Models.swift b/Sources/ForgeLibrary/Models.swift new file mode 100644 index 0000000..5ccdebc --- /dev/null +++ b/Sources/ForgeLibrary/Models.swift @@ -0,0 +1,76 @@ +import Foundation + +public struct Project: Identifiable, Sendable, Equatable { + public let id: UUID + public var name: String + public var createdAt: Date + + public init(id: UUID = UUID(), name: String, createdAt: Date = Date()) { + self.id = id + self.name = name + self.createdAt = createdAt + } +} + +public struct Day: Identifiable, Sendable, Equatable { + public let id: UUID + public var projectID: UUID + public var label: String + /// Shooting date, ISO yyyy-MM-dd. + public var date: String + + public init(id: UUID = UUID(), projectID: UUID, label: String, date: String) { + self.id = id + self.projectID = projectID + self.label = label + self.date = date + } +} + +public struct Shot: Identifiable, Sendable, Equatable { + public let id: UUID + public var dayID: UUID + public var clipName: String + public var cameraSlot: String + public var timecodeIn: String? + public var timecodeOut: String? + public var exposureIndex: Int? + public var whiteBalance: Int? + public var tint: Int? + public var fps: Double? + /// Serialized GradeSnapshot JSON at rec start. + public var gradeSnapshot: Data? + /// Path to still grab, if any. + public var stillPath: String? + public var createdAt: Date + + public init( + id: UUID = UUID(), + dayID: UUID, + clipName: String, + cameraSlot: String, + timecodeIn: String? = nil, + timecodeOut: String? = nil, + exposureIndex: Int? = nil, + whiteBalance: Int? = nil, + tint: Int? = nil, + fps: Double? = nil, + gradeSnapshot: Data? = nil, + stillPath: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.dayID = dayID + self.clipName = clipName + self.cameraSlot = cameraSlot + self.timecodeIn = timecodeIn + self.timecodeOut = timecodeOut + self.exposureIndex = exposureIndex + self.whiteBalance = whiteBalance + self.tint = tint + self.fps = fps + self.gradeSnapshot = gradeSnapshot + self.stillPath = stillPath + self.createdAt = createdAt + } +} diff --git a/Tests/ForgeLibraryTests/LibraryStoreTests.swift b/Tests/ForgeLibraryTests/LibraryStoreTests.swift new file mode 100644 index 0000000..8e610f2 --- /dev/null +++ b/Tests/ForgeLibraryTests/LibraryStoreTests.swift @@ -0,0 +1,129 @@ +import XCTest +import Foundation +@testable import ForgeLibrary + +final class LibraryStoreTests: XCTestCase { + + var dbPath: String! + var store: LibraryStore! + + override func setUp() async throws { + dbPath = NSTemporaryDirectory() + "forge-test-\(UUID().uuidString).db" + store = try LibraryStore(path: dbPath) + } + + override func tearDown() async throws { + store = nil + try? FileManager.default.removeItem(atPath: dbPath) + } + + // Schema migration table exists with v1. + func testMigrationVersion() throws { + XCTAssertEqual(try store.schemaVersion(), 1) + } + + // Project CRUD. + func testProjectCRUD() throws { + let p = try store.createProject(name: "Feature X") + XCTAssertFalse(p.id.uuidString.isEmpty) + XCTAssertEqual(p.name, "Feature X") + + let all = try store.listProjects() + XCTAssertEqual(all.count, 1) + XCTAssertEqual(all[0].name, "Feature X") + + try store.deleteProject(id: p.id) + XCTAssertTrue(try store.listProjects().isEmpty) + } + + // Day belongs to project. + func testDayCRUD() throws { + let p = try store.createProject(name: "P") + let d = try store.createDay(projectID: p.id, label: "Day 01", date: "2026-07-10") + let days = try store.listDays(projectID: p.id) + XCTAssertEqual(days.count, 1) + XCTAssertEqual(days[0].label, "Day 01") + XCTAssertEqual(days[0].date, "2026-07-10") + XCTAssertEqual(days[0].id, d.id) + } + + // Shot CRUD with metadata + grade snapshot blob round-trip. + func testShotRoundTrip() throws { + let p = try store.createProject(name: "P") + let d = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10") + let snapshot = Data(#"{"nodes":[],"version":1}"#.utf8) + + var shot = Shot( + dayID: d.id, + clipName: "A001C001_260710_R1CD", + cameraSlot: "A-Cam", + timecodeIn: "10:00:00:00", + timecodeOut: nil, + exposureIndex: 800, + whiteBalance: 5600, + tint: 0, + fps: 24, + gradeSnapshot: snapshot) + shot = try store.createShot(shot) + + var fetched = try XCTUnwrap(store.shot(id: shot.id)) + XCTAssertEqual(fetched.clipName, "A001C001_260710_R1CD") + XCTAssertEqual(fetched.cameraSlot, "A-Cam") + XCTAssertEqual(fetched.exposureIndex, 800) + XCTAssertEqual(fetched.gradeSnapshot, snapshot) + XCTAssertNil(fetched.timecodeOut) + + // Update TC out on rec stop. + fetched.timecodeOut = "10:02:30:12" + try store.updateShot(fetched) + let updated = try XCTUnwrap(store.shot(id: shot.id)) + XCTAssertEqual(updated.timecodeOut, "10:02:30:12") + } + + // Shots listed per day in insert order. + func testListShotsByDay() throws { + let p = try store.createProject(name: "P") + let d = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10") + for i in 1...3 { + _ = try store.createShot(Shot(dayID: d.id, clipName: "A001C00\(i)", cameraSlot: "A-Cam")) + } + let shots = try store.listShots(dayID: d.id) + XCTAssertEqual(shots.map(\.clipName), ["A001C001", "A001C002", "A001C003"]) + } + + // Concurrent writers serialized safely. + func testConcurrentWrites() async throws { + let p = try store.createProject(name: "P") + let d = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10") + let s = store! + let dayID = d.id + await withTaskGroup(of: Void.self) { group in + for i in 0..<50 { + group.addTask { + _ = try? s.createShot(Shot(dayID: dayID, clipName: "C\(i)", cameraSlot: "A")) + } + } + } + XCTAssertEqual(try store.listShots(dayID: d.id).count, 50) + } + + // Reopen db: data persists. + func testPersistence() throws { + let p = try store.createProject(name: "Persist") + store = nil + store = try LibraryStore(path: dbPath) + let all = try store.listProjects() + XCTAssertEqual(all.count, 1) + XCTAssertEqual(all[0].id, p.id) + } + + // Cascade: deleting project removes days + shots. + func testCascadeDelete() throws { + let p = try store.createProject(name: "P") + let d = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10") + _ = try store.createShot(Shot(dayID: d.id, clipName: "C1", cameraSlot: "A")) + try store.deleteProject(id: p.id) + XCTAssertTrue(try store.listDays(projectID: p.id).isEmpty) + XCTAssertTrue(try store.listShots(dayID: d.id).isEmpty) + } +} diff --git a/Tests/ForgeLibraryTests/PlaceholderTests.swift b/Tests/ForgeLibraryTests/PlaceholderTests.swift deleted file mode 100644 index 7b17a2a..0000000 --- a/Tests/ForgeLibraryTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeLibraryPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -}