Download Sources/PromptGarageCore/Database/DatabaseBootstrapService.swift from smlflg/PromtGarageMac: direct link, hf CLI and curl.
- Browser
- Download file 15.8 kB
-
https://huggingface.co/smlflg/PromtGarageMac/resolve/main/Sources/PromptGarageCore/Database/DatabaseBootstrapService.swift
- Command line
-
hf download hf://smlflg/PromtGarageMac/Sources/PromptGarageCore/Database/DatabaseBootstrapService.swift
-
curl -L -o DatabaseBootstrapService.swift https://huggingface.co/smlflg/PromtGarageMac/resolve/main/Sources/PromptGarageCore/Database/DatabaseBootstrapService.swift
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 | |
| } | |
| } | |
| } | |