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
        }
    }
}