File size: 15,816 Bytes
4977db2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 | 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
}
}
}
|