luminate/Sources/LuminateStore/Preferences/SQLitePreferenceStore.swift

140 lines
5.5 KiB
Swift

//
// SQLitePreferenceStore.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 Foundation
import Logging
import LuminateCore
private let logger = Logger(label: "\(AppInfo.identifier).store")
/// A SQLite-backed preference store with an ordered asynchronous write queue.
package actor SQLitePreferenceStore: PreferenceStoring {
private let database: SQLiteDatabase
package nonisolated let initialSnapshot: [String: StoredPreference]
private nonisolated let continuation: AsyncStream<PreferenceWrite>.Continuation
private var pendingFailure: PreferenceStoreError?
/// Opens, migrates, and snapshots the preference database at `url`.
///
/// - Parameter url: The database file location.
/// - Throws: ``PreferenceStoreError`` when the database cannot be opened or migrated.
package init(url: URL) throws {
let database = try SQLiteDatabase(url: url)
try SchemaMigrator.migrate(database)
initialSnapshot = try Self.loadAllPreferences(from: database)
self.database = database
let (stream, continuation) = AsyncStream<PreferenceWrite>.makeStream(bufferingPolicy: .unbounded)
self.continuation = continuation
Task { [weak self] in
for await write in stream {
guard let self else { break }
await self.apply(write)
}
}
}
deinit {
continuation.finish()
}
/// Queues a value update or deletion without blocking the caller.
package nonisolated func enqueue(_ key: String, _ value: StoredPreference?) {
continuation.yield(.set(key: key, value: value))
}
/// Returns after queued writes have been committed to SQLite.
///
/// - Throws: The first write failure recorded since the previous flush.
package nonisolated func flush() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
self.continuation.yield(
.barrier { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
})
}
}
private func apply(_ write: PreferenceWrite) {
switch write {
case .set(let key, let value):
do {
try Self.writePreference(to: database, key: key, value: value)
} catch {
let failure = (error as? PreferenceStoreError) ?? .sqlite(message: "\(error)")
logger.error("Preference write failed", error: failure, metadata: ["key": "\(key)"])
if pendingFailure == nil {
pendingFailure = failure
}
}
case .barrier(let resume):
let failure = pendingFailure
pendingFailure = nil
resume(failure)
}
}
/// Reads every non-NULL row from the preference table.
///
/// - Parameter database: The open SQLite connection containing the preference table.
/// - Returns: Raw preference values keyed by their stable row names.
/// - Throws: ``PreferenceStoreError`` when querying the table fails.
private static func loadAllPreferences(from database: SQLiteDatabase) throws -> [String: StoredPreference] {
let statement = try SQLiteStatement(database: database, sql: "SELECT key, value FROM preference;")
var values: [String: StoredPreference] = [:]
while try statement.step() {
guard let key = statement.text(at: 0), let value = statement.preference(at: 1) else { continue }
values[key] = value
}
return values
}
/// Inserts or updates one preference row, or deletes it when `value` is `nil`.
///
/// - Parameters:
/// - database: The open SQLite connection containing the preference table.
/// - key: The stable row key.
/// - value: The scalar value to store, or `nil` to delete the row.
/// - Throws: ``PreferenceStoreError`` when the statement fails.
private static func writePreference(
to database: SQLiteDatabase,
key: String,
value: StoredPreference?
) throws {
if let value {
let statement = try SQLiteStatement(
database: database,
sql: "INSERT INTO preference (key, value) VALUES (?1, ?2) "
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value;"
)
try statement.bind(1, text: key)
try statement.bind(2, value)
_ = try statement.step()
} else {
let statement = try SQLiteStatement(database: database, sql: "DELETE FROM preference WHERE key = ?1;")
try statement.bind(1, text: key)
_ = try statement.step()
}
}
}