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