luminate/Sources/LuminateStore/Database/SQLiteDatabase.swift

139 lines
5.2 KiB
Swift

//
// SQLiteDatabase.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import CSQLite
import Foundation
import LuminateCore
/// A synchronous SQLite connection owned by the storage actor.
package final class SQLiteDatabase {
package let handle: OpaquePointer
/// Opens or creates a private SQLite database at `url` and configures local durability.
///
/// - Parameter url: The database file location.
/// - Throws: ``PreferenceStoreError/cannotOpen(path:message:)`` when SQLite cannot open it.
package init(url: URL) throws {
let directory = url.deletingLastPathComponent()
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
var opened: OpaquePointer?
let result = url.path.withCString { path in
sqlite3_open_v2(
path,
&opened,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX,
nil
)
}
guard result == SQLITE_OK, let opened else {
let message = opened.map { String(cString: sqlite3_errmsg($0)) } ?? "SQLite returned no handle"
if let opened { sqlite3_close_v2(opened) }
throw PreferenceStoreError.cannotOpen(path: url.path, message: message)
}
handle = opened
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
try exec("PRAGMA journal_mode = WAL;")
try exec("PRAGMA synchronous = NORMAL;")
try exec("PRAGMA foreign_keys = ON;")
// SQLite has no busy handler by default, so concurrent writers would fail immediately.
try exec("PRAGMA busy_timeout = 5000;")
}
deinit {
sqlite3_close_v2(handle)
}
/// The latest SQLite diagnostic for this connection.
package var errorMessage: String {
String(cString: sqlite3_errmsg(handle))
}
/// Executes SQL that does not return rows.
///
/// - Parameter sql: The SQL statements to execute.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` when execution fails.
package func exec(_ sql: String) throws {
var errorPointer: UnsafeMutablePointer<CChar>?
let result = sqlite3_exec(handle, sql, nil, nil, &errorPointer)
defer {
if let errorPointer { sqlite3_free(errorPointer) }
}
guard result == SQLITE_OK else {
let message = errorPointer.map { String(cString: $0) } ?? errorMessage
throw PreferenceStoreError.sqlite(message: message)
}
}
/// Runs a closure inside one SQLite transaction and rolls back failures.
///
/// - Parameter body: Statements to execute while the transaction is open.
/// - Throws: The first error raised by the body or transaction control statements.
package func transaction(_ body: () throws -> Void) throws {
try exec("BEGIN;")
do {
try body()
try exec("COMMIT;")
} catch {
try? exec("ROLLBACK;")
throw error
}
}
/// Reads SQLite's schema version pragma.
package var userVersion: Int32 {
get throws {
let statement = try SQLiteStatement(database: self, sql: "PRAGMA user_version;")
guard try statement.step() else { throw PreferenceStoreError.sqlite(message: errorMessage) }
return statement.integer(at: 0)
}
}
/// Sets SQLite's schema version pragma.
///
/// - Parameter version: The non-negative schema version.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` when SQLite rejects the pragma.
package func setUserVersion(_ version: Int32) throws {
try exec("PRAGMA user_version = \(version);")
}
/// Reads SQLite's application identifier pragma.
package var applicationID: Int32 {
get throws {
let statement = try SQLiteStatement(database: self, sql: "PRAGMA application_id;")
guard try statement.step() else { throw PreferenceStoreError.sqlite(message: errorMessage) }
return statement.integer(at: 0)
}
}
/// Sets SQLite's application identifier pragma.
///
/// - Parameter applicationID: The application identifier to persist in the file header.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` when SQLite rejects the pragma.
package func setApplicationID(_ applicationID: Int32) throws {
try exec("PRAGMA application_id = \(applicationID);")
}
}