// // SQLitePreferenceStore.swift // // Copyright 2026 Brendan Szymanski // // 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 . // // 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.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.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) 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() } } }