From c6a74396482ff472ab656a6cf4cda4158eb0a95b Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 24 Jul 2026 09:16:42 +0200 Subject: [PATCH] Phase 4 seam: in-memory DatabaseService + DB injection for ContextSelectionService DatabaseService gains a testable init(dbQueue:) plus a makeInMemory() convenience and schema-introspection helpers (tableExists/columnNames), so migration tests don't need the test target to import GRDB directly. ContextSelectionService now takes an injected DatabaseService (defaults to .shared) and its DB-coupled methods (smartSelection, getSummariesForExcludedRange, isMessageStarred) are bumped to internal. No logic changes to existing call sites. --- oAI/Services/ContextSelectionService.swift | 17 +++++++----- oAI/Services/DatabaseService.swift | 30 ++++++++++++++++++---- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/oAI/Services/ContextSelectionService.swift b/oAI/Services/ContextSelectionService.swift index 310f735..1311b85 100644 --- a/oAI/Services/ContextSelectionService.swift +++ b/oAI/Services/ContextSelectionService.swift @@ -47,7 +47,12 @@ enum SelectionStrategy { final class ContextSelectionService { static let shared = ContextSelectionService() - private init() {} + private let db: DatabaseService + + /// `db` defaults to the shared on-disk instance; tests inject an in-memory `DatabaseService`. + init(db: DatabaseService = .shared) { + self.db = db + } /// Select context messages using the specified strategy func selectContext( @@ -98,7 +103,7 @@ final class ContextSelectionService { // MARK: - Smart Selection Algorithm - private func smartSelection(allMessages: [Message], maxTokens: Int, conversationId: UUID? = nil) -> ContextWindow { + func smartSelection(allMessages: [Message], maxTokens: Int, conversationId: UUID? = nil) -> ContextWindow { guard !allMessages.isEmpty else { return ContextWindow(messages: [], summaries: [], totalTokens: 0, excludedCount: 0) } @@ -191,12 +196,12 @@ final class ContextSelectionService { } /// Get summaries for excluded message ranges - private func getSummariesForExcludedRange( + func getSummariesForExcludedRange( conversationId: UUID, totalMessages: Int, selectedCount: Int ) -> [String] { - guard let summaryRecords = try? DatabaseService.shared.getConversationSummaries(conversationId: conversationId) else { + guard let summaryRecords = try? db.getConversationSummaries(conversationId: conversationId) else { return [] } @@ -238,8 +243,8 @@ final class ContextSelectionService { } /// Check if a message is starred by the user - private func isMessageStarred(_ message: Message) -> Bool { - guard let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) else { + func isMessageStarred(_ message: Message) -> Bool { + guard let metadata = try? db.getMessageMetadata(messageId: message.id) else { return false } return metadata.user_starred == 1 diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index 9bf46c2..a56b195 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -154,7 +154,14 @@ final class DatabaseService: Sendable { (try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601)) } - nonisolated private init() { + /// Test seam: build a DatabaseService around a caller-provided queue (e.g. an in-memory `DatabaseQueue()`). + /// Each test should create its own fresh instance — never mutate `.shared` from a test. + nonisolated init(dbQueue: DatabaseQueue) { + self.dbQueue = dbQueue + try! migrator.migrate(dbQueue) + } + + nonisolated private convenience init() { let fileManager = FileManager.default let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true) @@ -163,12 +170,25 @@ final class DatabaseService: Sendable { let dbPath = dbDirectory.appendingPathComponent("oai_conversations.db").path Log.db.info("Opening database at \(dbPath)") - dbQueue = try! DatabaseQueue(path: dbPath) - - try! migrator.migrate(dbQueue) + self.init(dbQueue: try! DatabaseQueue(path: dbPath)) } - private nonisolated var migrator: DatabaseMigrator { + /// Test seam: a throwaway in-memory instance with all migrations applied. Avoids requiring + /// `import GRDB` from the test target just to construct a `DatabaseQueue()`. + nonisolated static func makeInMemory() -> DatabaseService { + DatabaseService(dbQueue: try! DatabaseQueue()) + } + + /// Test seam: schema introspection, so migration tests don't need `import GRDB` either. + nonisolated func tableExists(_ name: String) -> Bool { + (try? dbQueue.read { db in try db.tableExists(name) }) ?? false + } + + nonisolated func columnNames(in table: String) -> [String] { + (try? dbQueue.read { db in try db.columns(in: table).map(\.name) }) ?? [] + } + + nonisolated var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() migrator.registerMigration("v1") { db in