File size: 9,254 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
import Foundation
import GRDB

public enum DatabaseSchema {
    /// Columns that are required in both the legacy Python database and the
    /// native application database. `prompted_at` is intentionally not part of
    /// this set: source databases predate that column and must remain valid.
    public static let requiredLegacyColumns: Set<String> = [
        "id", "title", "content", "source", "is_starred", "tags", "model",
        "rating", "use_count", "created_at", "updated_at",
    ]

    public static let promptedAtColumn = "prompted_at"

    public static let createSQL = """
        CREATE TABLE IF NOT EXISTS prompts (
            id INTEGER PRIMARY KEY,
            title TEXT NOT NULL,
            content TEXT NOT NULL,
            source TEXT,
            is_starred INTEGER DEFAULT 0,
            tags TEXT,
            model TEXT,
            rating INTEGER,
            use_count INTEGER DEFAULT 0,
            created_at TEXT DEFAULT (datetime('now')),
            updated_at TEXT DEFAULT (datetime('now')),
            prompted_at TEXT
        );

        CREATE VIRTUAL TABLE IF NOT EXISTS prompts_fts USING fts5(
            title, content, tags, content=prompts, content_rowid=id
        );

        CREATE TRIGGER IF NOT EXISTS prompts_ai AFTER INSERT ON prompts BEGIN
            INSERT INTO prompts_fts(rowid, title, content, tags)
            VALUES (new.id, new.title, new.content, new.tags);
        END;

        CREATE TRIGGER IF NOT EXISTS prompts_ad AFTER DELETE ON prompts BEGIN
            INSERT INTO prompts_fts(prompts_fts, rowid, title, content, tags)
            VALUES('delete', old.id, old.title, old.content, old.tags);
        END;

        CREATE TRIGGER IF NOT EXISTS prompts_au AFTER UPDATE ON prompts BEGIN
            INSERT INTO prompts_fts(prompts_fts, rowid, title, content, tags)
            VALUES('delete', old.id, old.title, old.content, old.tags);
            INSERT INTO prompts_fts(rowid, title, content, tags)
            VALUES (new.id, new.title, new.content, new.tags);
        END;
        """

    public static func createIfNeeded(in db: Database) throws {
        try db.execute(sql: createSQL)
        try migrateDestinationIfNeeded(in: db)
        try repairSearchIndexIfNeeded(in: db)
    }

    /// Applies native-only, forward migrations. This must only be called for
    /// the application-owned copy, never while validating a selected source.
    /// Existing rows remain NULL by SQLite definition; there is deliberately
    /// no backfill from `created_at` or `updated_at`.
    public static func migrateDestinationIfNeeded(in db: Database) throws {
        let columns = try promptColumns(in: db)
        guard !columns.contains(promptedAtColumn) else { return }
        try db.execute(sql: "ALTER TABLE prompts ADD COLUMN prompted_at TEXT")
    }

    /// Verifies the actual FTS5 index against the external content table and
    /// rebuilds it only in the writable app copy when needed. `COUNT(*)` on an
    /// external-content FTS table mirrors `prompts` and cannot detect a stale
    /// or empty index by itself.
    public static func repairSearchIndexIfNeeded(in db: Database) throws {
        do {
            try verifySearchIndex(in: db)
        } catch {
            try db.execute(
                sql: "INSERT INTO prompts_fts(prompts_fts) VALUES('rebuild')"
            )
            try verifySearchIndex(in: db)
        }
    }

    /// Performs full validation on an application-owned writable database (or
    /// a writable safety copy). FTS5's external-content integrity command is
    /// syntactically an INSERT, even though it only checks the index, so this
    /// method must not be called directly on a read-only source connection.
    public static func validate(_ queue: DatabaseQueue) throws -> DatabaseValidationReport {
        let report = try validateStructureAndCounts(queue)
        do {
            try queue.writeWithoutTransaction { db in
                try verifySearchIndex(in: db)
            }
        } catch {
            throw DatabaseValidationError.ftsIndexInvalid(error.localizedDescription)
        }
        return report
    }

    /// Fully validates a read-only database without executing any write
    /// statement against it. The source is copied with SQLite's online backup
    /// API and the FTS integrity command runs only against the in-memory copy.
    public static func validateReadOnly(
        _ source: DatabaseQueue
    ) throws -> DatabaseValidationReport {
        let snapshot = try DatabaseQueue()
        defer { try? snapshot.close() }
        try source.backup(to: snapshot)
        return try validate(snapshot)
    }

    /// Structural/count validation is intentionally separate from the full
    /// check. It is used while holding a read-only source open during an atomic
    /// backup; the resulting writable copy is fully validated before install.
    static func validateStructureAndCounts(
        _ reader: any DatabaseReader
    ) throws -> DatabaseValidationReport {
        try reader.read { db in
            let integrity = try String.fetchOne(db, sql: "PRAGMA integrity_check") ?? "unknown"
            guard integrity.lowercased() == "ok" else {
                throw DatabaseValidationError.integrityFailed(integrity)
            }

            let tableNames = Set(try String.fetchAll(
                db,
                sql: "SELECT name FROM sqlite_master WHERE type IN ('table', 'view')"
            ))
            guard tableNames.contains("prompts"), tableNames.contains("prompts_fts") else {
                throw DatabaseValidationError.missingTables
            }

            let columns = try promptColumns(in: db)
            let missingColumns = requiredLegacyColumns.subtracting(columns)
            guard missingColumns.isEmpty else {
                throw DatabaseValidationError.missingColumns(missingColumns.sorted())
            }

            let triggerNames = Set(try String.fetchAll(
                db,
                sql: "SELECT name FROM sqlite_master WHERE type = 'trigger'"
            ))
            let requiredTriggers: Set<String> = ["prompts_ai", "prompts_ad", "prompts_au"]
            let missingTriggers = requiredTriggers.subtracting(triggerNames)
            guard missingTriggers.isEmpty else {
                throw DatabaseValidationError.missingTriggers(missingTriggers.sorted())
            }

            let promptCount = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM prompts") ?? 0
            let ftsCount = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM prompts_fts") ?? 0
            guard promptCount == ftsCount else {
                throw DatabaseValidationError.ftsMismatch(prompts: promptCount, fts: ftsCount)
            }

            return DatabaseValidationReport(
                integrity: integrity,
                promptCount: promptCount,
                ftsCount: ftsCount,
                hasPromptedAt: columns.contains(promptedAtColumn)
            )
        }
    }

    private static func promptColumns(in db: Database) throws -> Set<String> {
        let rows = try Row.fetchAll(db, sql: "PRAGMA table_info(prompts)")
        return Set(rows.compactMap { row -> String? in row["name"] })
    }

    private static func verifySearchIndex(in db: Database) throws {
        // rank=1 asks FTS5 to compare the index with the external content
        // table, not merely to validate its internal b-tree structure.
        try db.execute(
            sql: """
                INSERT INTO prompts_fts(prompts_fts, rank)
                VALUES('integrity-check', 1)
                """
        )
    }
}

public struct DatabaseValidationReport: Equatable, Sendable {
    public let integrity: String
    public let promptCount: Int
    public let ftsCount: Int
    public let hasPromptedAt: Bool

    public init(
        integrity: String,
        promptCount: Int,
        ftsCount: Int,
        hasPromptedAt: Bool = false
    ) {
        self.integrity = integrity
        self.promptCount = promptCount
        self.ftsCount = ftsCount
        self.hasPromptedAt = hasPromptedAt
    }
}

public enum DatabaseValidationError: LocalizedError, Equatable, Sendable {
    case integrityFailed(String)
    case missingTables
    case missingColumns([String])
    case missingTriggers([String])
    case ftsMismatch(prompts: Int, fts: Int)
    case ftsIndexInvalid(String)

    public var errorDescription: String? {
        switch self {
        case let .integrityFailed(message):
            "Die SQLite-Integritätsprüfung ist fehlgeschlagen: \(message)"
        case .missingTables:
            "Die Datenbank enthält nicht die Tabellen prompts und prompts_fts."
        case let .missingColumns(columns):
            "Der prompts-Tabelle fehlen Spalten: \(columns.joined(separator: ", "))."
        case let .missingTriggers(triggers):
            "Der Datenbank fehlen FTS-Trigger: \(triggers.joined(separator: ", "))."
        case let .ftsMismatch(prompts, fts):
            "Prompt- und FTS-Zeilen stimmen nicht überein (\(prompts) vs. \(fts))."
        case let .ftsIndexInvalid(message):
            "Der FTS-Suchindex ist inkonsistent oder nicht prüfbar: \(message)"
        }
    }
}