140 lines
5.5 KiB
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()
|
|
}
|
|
}
|
|
}
|