139 lines
5.2 KiB
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);")
|
|
}
|
|
|
|
}
|