PromtGarageMac / Sources /PromptGarageCore /Database /DatabaseBootstrapService.swift
smlflg's picture
Initial public upload from Projekte/PromtGarageMac
4977db2 verified
Raw History Blame Contribute Delete
15.8 kB
import Foundation
import GRDB
public struct DatabaseBootstrapService: Sendable {
public let applicationSupportDirectoryURL: URL
public let databaseURL: URL
/// `applicationSupportRoot` is injectable for tests. When omitted it is
/// the user's Application Support directory; the service always creates a
/// dedicated `PromptGarage` child directory below it.
public init(applicationSupportRoot: URL? = nil) throws {
let fileManager = FileManager.default
let root: URL
if let applicationSupportRoot {
root = applicationSupportRoot
} else {
root = try fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
}
let directory = root.appendingPathComponent(
"PromptGarage",
isDirectory: true
)
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true
)
applicationSupportDirectoryURL = directory
databaseURL = directory.appendingPathComponent("prompts.db")
}
public func inspect() -> DatabaseBootstrapState {
let fileManager = FileManager.default
var isDirectory: ObjCBool = false
guard fileManager.fileExists(
atPath: databaseURL.path,
isDirectory: &isDirectory
) else {
return .needsDatabase
}
guard !isDirectory.boolValue else {
return .failed("Am Datenbankpfad liegt ein Ordner statt einer Datei.")
}
guard fileManager.isReadableFile(atPath: databaseURL.path) else {
return .failed("Die vorhandene PromptGarage-Datenbank ist nicht lesbar.")
}
do {
if fileManager.isWritableFile(atPath: databaseURL.path) {
// This is the app-owned destination. A structurally valid,
// writable copy may receive the nullable migration and repair
// a stale FTS index before it is reported ready.
return .ready(try validateDatabaseWritable(at: databaseURL))
}
// A read-only app copy cannot be repaired. Full validation runs on
// a transient SQLite backup so a stale FTS index is surfaced as
// invalid instead of being opened as ready/read-only.
return .readOnly(try validateDatabaseReadOnly(at: databaseURL))
} catch let error as DatabaseValidationError {
return .invalid(error)
} catch {
return .failed(error.localizedDescription)
}
}
public func openExisting() throws -> PromptRepository? {
switch inspect() {
case .needsDatabase:
return nil
case .validating:
return nil
case .ready:
return try PromptRepository(databaseURL: databaseURL)
case .readOnly:
return try PromptRepository(databaseURL: databaseURL, readOnly: true)
case let .invalid(error):
throw DatabaseBootstrapError.invalidExistingDatabase(error)
case let .failed(message):
throw DatabaseBootstrapError.operationFailed(message)
}
}
/// Creates the native database when none exists. Existing data is never
/// erased: a valid database is opened, and an invalid one is surfaced.
public func startEmpty() throws -> PromptRepository {
if let repository = try openExisting() {
return repository
}
return try PromptRepository(databaseURL: databaseURL)
}
/// Validates a selected legacy or native database through a read-only
/// connection. Legacy databases are allowed to omit `prompted_at`.
public func validateSource(at sourceURL: URL) throws -> DatabaseValidationReport {
try validateSourceLocation(sourceURL)
return try validateDatabaseReadOnly(at: sourceURL)
}
/// Makes a consistent SQLite online backup into the application directory,
/// migrates only that temporary copy, validates it, and atomically installs
/// it. The selected source database is never opened for writing.
public func installDatabase(from sourceURL: URL) throws -> PromptRepository {
// Installing is a first-launch-only operation. Never use a selected
// source as an automatic recovery/replacement for an app database: an
// existing destination may contain the user's only current Mac data,
// even when it is structurally invalid. Refuse before opening or
// backing up the source so recovery remains an explicit filesystem
// action owned by the user.
try refuseInstallWhenDestinationExists()
try validateSourceLocation(sourceURL)
let fileManager = FileManager.default
let temporaryURL = applicationSupportDirectoryURL.appendingPathComponent(
".prompts-import-\(UUID().uuidString).db"
)
defer { Self.removeDatabaseFiles(at: temporaryURL) }
var sourceConfiguration = Configuration()
sourceConfiguration.label = "PromptGarage.bootstrap.source"
sourceConfiguration.readonly = true
sourceConfiguration.busyMode = .timeout(5)
let source = try DatabaseQueue(
path: sourceURL.path,
configuration: sourceConfiguration
)
defer { try? source.close() }
var destinationConfiguration = Configuration()
destinationConfiguration.label = "PromptGarage.bootstrap.temporary"
destinationConfiguration.busyMode = .timeout(5)
let temporary = try DatabaseQueue(
path: temporaryURL.path,
configuration: destinationConfiguration
)
defer { try? temporary.close() }
// data_version changes on this connection whenever another connection
// commits to the selected source. Holding it stable from validation
// through the final pre-swap gate proves that our dynamic row counts
// describe the same source snapshot that was backed up.
let sourceVersion = try Self.dataVersion(of: source)
let sourceReport = try DatabaseSchema.validateStructureAndCounts(source)
try source.backup(to: temporary)
guard try Self.dataVersion(of: source) == sourceVersion else {
throw DatabaseBootstrapError.sourceChangedDuringCopy
}
// Validate the exact source snapshot before any native migration or
// repair. This catches a stale external-content FTS index that plain
// COUNT(*) parity cannot see, without ever writing to the source.
let copiedSourceReport = try DatabaseSchema.validate(temporary)
try Self.validateCopyParity(
source: sourceReport,
destination: copiedSourceReport
)
try temporary.write { db in
// The source already passed strict legacy validation. Only the
// app-owned copy receives the forward-only nullable column.
try DatabaseSchema.migrateDestinationIfNeeded(in: db)
try DatabaseSchema.repairSearchIndexIfNeeded(in: db)
}
let stagedReport = try DatabaseSchema.validate(temporary)
try Self.validateCopyParity(
source: sourceReport,
destination: stagedReport
)
guard stagedReport.hasPromptedAt else {
throw DatabaseBootstrapError.operationFailed(
"Die App-Kopie konnte nicht auf prompted_at migriert werden."
)
}
// Apply destination permissions before the atomic swap. If this fails,
// the currently installed database remains untouched.
try fileManager.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: temporaryURL.path
)
// Reject a moving source instead of installing a copy whose displayed
// source counts no longer match. The user can retry against the next
// stable dynamic snapshot; no hard-coded row count is involved.
guard try Self.dataVersion(of: source) == sourceVersion else {
throw DatabaseBootstrapError.sourceChangedDuringCopy
}
// Close both handles before the rename so that neither SQLite nor
// Foundation keeps a file descriptor to the staging path.
try temporary.close()
try source.close()
do {
// Re-check immediately before the move to close the race between
// first-launch validation and installation. `moveItem` is the
// only allowed install primitive; there is intentionally no
// replacement path.
try refuseInstallWhenDestinationExists()
try fileManager.moveItem(at: temporaryURL, to: databaseURL)
Self.removeSQLiteSidecars(for: databaseURL)
} catch let error as DatabaseBootstrapError {
throw error
} catch {
throw DatabaseBootstrapError.couldNotInstallDestination(
error.localizedDescription
)
}
do {
return try PromptRepository(databaseURL: databaseURL)
} catch {
throw DatabaseBootstrapError.installedDatabaseInvalid(
error.localizedDescription
)
}
}
private func validateSourceLocation(_ sourceURL: URL) throws {
let fileManager = FileManager.default
var isDirectory: ObjCBool = false
guard fileManager.fileExists(
atPath: sourceURL.path,
isDirectory: &isDirectory
) else {
throw DatabaseBootstrapError.sourceMissing(sourceURL)
}
guard !isDirectory.boolValue else {
throw DatabaseBootstrapError.sourceIsDirectory(sourceURL)
}
guard fileManager.isReadableFile(atPath: sourceURL.path) else {
throw DatabaseBootstrapError.sourceUnreadable(sourceURL)
}
let source = sourceURL.standardizedFileURL.resolvingSymlinksInPath()
let destination = databaseURL.standardizedFileURL.resolvingSymlinksInPath()
guard source != destination else {
throw DatabaseBootstrapError.sourceMatchesDestination
}
}
private func refuseInstallWhenDestinationExists() throws {
guard !FileManager.default.fileExists(atPath: databaseURL.path) else {
throw DatabaseBootstrapError.destinationAlreadyExists(databaseURL)
}
}
private func validateDatabaseReadOnly(
at url: URL
) throws -> DatabaseValidationReport {
var configuration = Configuration()
configuration.label = "PromptGarage.validation"
configuration.readonly = true
configuration.busyMode = .timeout(5)
let database = try DatabaseQueue(path: url.path, configuration: configuration)
defer { try? database.close() }
return try DatabaseSchema.validateReadOnly(database)
}
private func validateDatabaseWritable(
at url: URL
) throws -> DatabaseValidationReport {
var configuration = Configuration()
configuration.label = "PromptGarage.writable-validation"
configuration.busyMode = .timeout(5)
let database = try DatabaseQueue(path: url.path, configuration: configuration)
defer { try? database.close() }
_ = try DatabaseSchema.validateStructureAndCounts(database)
try database.write { db in
try DatabaseSchema.migrateDestinationIfNeeded(in: db)
try DatabaseSchema.repairSearchIndexIfNeeded(in: db)
}
return try DatabaseSchema.validate(database)
}
static func validateCopyParity(
source: DatabaseValidationReport,
destination: DatabaseValidationReport
) throws {
let counts = [
source.promptCount,
source.ftsCount,
destination.promptCount,
destination.ftsCount,
]
guard Set(counts).count == 1 else {
throw DatabaseBootstrapError.copyParityMismatch(
sourcePrompts: source.promptCount,
sourceFTS: source.ftsCount,
destinationPrompts: destination.promptCount,
destinationFTS: destination.ftsCount
)
}
}
private static func dataVersion(of queue: DatabaseQueue) throws -> Int64 {
try queue.read { db in
try Int64.fetchOne(db, sql: "PRAGMA data_version") ?? -1
}
}
private static func removeDatabaseFiles(at url: URL) {
let fileManager = FileManager.default
if fileManager.fileExists(atPath: url.path) {
try? fileManager.removeItem(at: url)
}
removeSQLiteSidecars(for: url)
}
private static func removeSQLiteSidecars(for url: URL) {
let fileManager = FileManager.default
for suffix in ["-wal", "-shm", "-journal"] {
let sidecar = URL(fileURLWithPath: url.path + suffix)
if fileManager.fileExists(atPath: sidecar.path) {
try? fileManager.removeItem(at: sidecar)
}
}
}
}
public enum DatabaseBootstrapState: Equatable, Sendable {
case needsDatabase
case validating
case ready(DatabaseValidationReport)
case invalid(DatabaseValidationError)
case readOnly(DatabaseValidationReport)
case failed(String)
}
public enum DatabaseBootstrapError: LocalizedError, Equatable, Sendable {
case sourceMissing(URL)
case sourceIsDirectory(URL)
case sourceUnreadable(URL)
case sourceMatchesDestination
case destinationAlreadyExists(URL)
case sourceChangedDuringCopy
case copyParityMismatch(
sourcePrompts: Int,
sourceFTS: Int,
destinationPrompts: Int,
destinationFTS: Int
)
case invalidExistingDatabase(DatabaseValidationError)
case couldNotInstallDestination(String)
case installedDatabaseInvalid(String)
case operationFailed(String)
public var errorDescription: String? {
switch self {
case let .sourceMissing(url):
"Die gewählte Datenbank existiert nicht: \(url.path)"
case let .sourceIsDirectory(url):
"Bitte eine prompts.db-Datei statt eines Ordners wählen: \(url.path)"
case let .sourceUnreadable(url):
"Die gewählte Datenbank ist nicht lesbar: \(url.path)"
case .sourceMatchesDestination:
"Die gewählte Quelle ist bereits die aktive PromptGarage-Datenbank."
case let .destinationAlreadyExists(url):
"Am App-Ziel existiert bereits eine Datenbank. PromptGarage ersetzt sie niemals automatisch. Beende die App und verschiebe oder benenne \(url.path) manuell um; starte PromptGarage danach erneut."
case .sourceChangedDuringCopy:
"Die Quelldatenbank wurde während der Sicherheitskopie geändert. Bitte den Import erneut starten."
case let .copyParityMismatch(
sourcePrompts,
sourceFTS,
destinationPrompts,
destinationFTS
):
"Die Vierfach-Prüfung ist fehlgeschlagen (Quelle prompts=\(sourcePrompts), Quelle FTS=\(sourceFTS), App-Kopie prompts=\(destinationPrompts), App-Kopie FTS=\(destinationFTS))."
case let .invalidExistingDatabase(error):
error.localizedDescription
case let .couldNotInstallDestination(message):
"Die App-Kopie konnte nicht atomar installiert werden: \(message)"
case let .installedDatabaseInvalid(message):
"Die installierte App-Kopie konnte nicht geöffnet werden: \(message)"
case let .operationFailed(message):
message
}
}
}