Files
oai-swift/oAI/Services/DatabaseService.swift
T
rune 6480a50eee Sync folder structure via Git Sync (folders.json), plus bugs found testing it
Folders and conversation→folder assignments now sync across machines:
- Folder gains updatedAt (v11 migration) to resolve renames/reparents
  last-write-wins across machines.
- New folders.json manifest at the sync repo root: folder tree +
  conversationId→folderId assignments, imported before conversation
  files so new conversations land in the right folder immediately.
- Local folders missing from the manifest are pruned (reparent-safe),
  guarded the same way conversation-orphan cleanup already is against
  an empty/stale manifest wiping everything.

Three real bugs found and fixed during live multi-machine testing:
- Sidebar never refreshed after Git Sync imported conversations/folders
  directly into the database — only reloaded on launch or when the
  advanced conversation list closed, with no equivalent hook for the
  Settings sheet.
- "Sync Now" exported before pulling, so it could write folders.json
  as an untracked file that then collided with the remote's tracked
  copy on the next pull ("untracked working tree files would be
  overwritten by merge"). Reordered to pull → import → export → push.
- Folder assignment only applied to brand-new conversations during
  import, so any conversation already synced to a machine before this
  feature existed never got filed — which in practice is every
  conversation on a second machine, not an edge case. Now backfills
  a folder assignment for existing conversations that aren't filed
  anywhere locally yet, without clobbering an already-set folderId.

Also renamed the "Initialize Repository" button to "Clone Repository"
(it's always been a git clone, not new-repo creation) across the UI,
localization catalog, and Help Book.
2026-08-03 11:48:23 +02:00

1274 lines
47 KiB
Swift

//
// DatabaseService.swift
// Confab
//
// SQLite persistence layer for conversations using GRDB
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
import GRDB
import os
// MARK: - Database Record Types
struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "conversations"
var id: String
var name: String
var createdAt: String
var updatedAt: String
var primaryModel: String?
var folderId: String?
}
struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "folders"
var id: String
var name: String
var sortOrder: Int
var createdAt: String
var parentId: String?
var updatedAt: String?
}
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "messages"
var id: String
var conversationId: String
var role: String
var content: String
var tokens: Int?
var cost: Double?
var timestamp: String
var sortOrder: Int
var modelId: String?
}
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "settings"
var key: String
var value: String
}
struct HistoryRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "command_history"
var id: String
var input: String
var timestamp: String
}
struct EmailLogRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "email_logs"
var id: String
var timestamp: String
var sender: String
var subject: String
var emailContent: String
var aiResponse: String?
var status: String // "success" or "error"
var errorMessage: String?
var tokens: Int?
var cost: Double?
var responseTime: Double?
var modelId: String?
}
struct MessageMetadataRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "message_metadata"
var message_id: String
var importance_score: Double
var user_starred: Int
var summary: String?
var chunk_index: Int
}
struct MessageEmbeddingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "message_embeddings"
var message_id: String
var embedding: Data
var embedding_model: String
var embedding_dimension: Int
var created_at: String
}
struct ConversationEmbeddingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "conversation_embeddings"
var conversation_id: String
var embedding: Data
var embedding_model: String
var embedding_dimension: Int
var created_at: String
}
struct ConversationSummaryRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "conversation_summaries"
var id: String
var conversation_id: String
var start_message_index: Int
var end_message_index: Int
var summary: String
var token_count: Int?
var created_at: String
var summary_model: String?
}
// MARK: - DatabaseService
final class DatabaseService: Sendable {
nonisolated static let shared = DatabaseService()
private let dbQueue: DatabaseQueue
// Command history limit - keep most recent 5000 entries
private nonisolated static let maxHistoryEntries = 5000
// ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable).
private nonisolated static let isoStyle = Date.ISO8601FormatStyle(
dateSeparator: .dash,
dateTimeSeparator: .standard,
timeSeparator: .colon,
timeZoneSeparator: .colon,
includingFractionalSeconds: true,
timeZone: .gmt
)
private nonisolated static func isoString(from date: Date) -> String {
isoStyle.format(date)
}
private nonisolated static func isoDate(from string: String) -> Date? {
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
}
/// 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)
try! fileManager.createDirectory(at: dbDirectory, withIntermediateDirectories: true)
let dbPath = dbDirectory.appendingPathComponent("oai_conversations.db").path
Log.db.info("Opening database at \(dbPath)")
self.init(dbQueue: try! DatabaseQueue(path: dbPath))
}
/// 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
try db.create(table: "conversations") { t in
t.primaryKey("id", .text)
t.column("name", .text).notNull()
t.column("createdAt", .text).notNull()
t.column("updatedAt", .text).notNull()
}
try db.create(table: "messages") { t in
t.primaryKey("id", .text)
t.column("conversationId", .text).notNull()
.references("conversations", onDelete: .cascade)
t.column("role", .text).notNull()
t.column("content", .text).notNull()
t.column("tokens", .integer)
t.column("cost", .double)
t.column("timestamp", .text).notNull()
t.column("sortOrder", .integer).notNull()
}
try db.create(
index: "messages_on_conversationId",
on: "messages",
columns: ["conversationId"]
)
}
migrator.registerMigration("v2") { db in
try db.create(table: "settings") { t in
t.primaryKey("key", .text)
t.column("value", .text).notNull()
}
}
migrator.registerMigration("v3") { db in
try db.create(table: "command_history") { t in
t.primaryKey("id", .text)
t.column("input", .text).notNull()
t.column("timestamp", .text).notNull()
}
try db.create(
index: "command_history_on_timestamp",
on: "command_history",
columns: ["timestamp"]
)
}
migrator.registerMigration("v4") { db in
// Add modelId to messages table (nullable for existing messages)
try db.alter(table: "messages") { t in
t.add(column: "modelId", .text)
}
// Add primaryModel to conversations table (nullable)
try db.alter(table: "conversations") { t in
t.add(column: "primaryModel", .text)
}
}
migrator.registerMigration("v5") { db in
// Email handler logs
try db.create(table: "email_logs") { t in
t.primaryKey("id", .text)
t.column("timestamp", .text).notNull()
t.column("sender", .text).notNull()
t.column("subject", .text).notNull()
t.column("emailContent", .text).notNull()
t.column("aiResponse", .text)
t.column("status", .text).notNull() // "success" or "error"
t.column("errorMessage", .text)
t.column("tokens", .integer)
t.column("cost", .double)
t.column("responseTime", .double)
t.column("modelId", .text)
}
try db.create(
index: "email_logs_on_timestamp",
on: "email_logs",
columns: ["timestamp"]
)
}
migrator.registerMigration("v6") { db in
// Message metadata for smart context selection
try db.create(table: "message_metadata") { t in
t.primaryKey("message_id", .text)
.references("messages", onDelete: .cascade)
t.column("importance_score", .double).notNull().defaults(to: 0.0)
t.column("user_starred", .integer).notNull().defaults(to: 0)
t.column("summary", .text)
t.column("chunk_index", .integer).notNull().defaults(to: 0)
}
try db.create(
index: "idx_message_metadata_importance",
on: "message_metadata",
columns: ["importance_score"]
)
try db.create(
index: "idx_message_metadata_starred",
on: "message_metadata",
columns: ["user_starred"]
)
}
migrator.registerMigration("v7") { db in
// Message embeddings for semantic search
try db.create(table: "message_embeddings") { t in
t.primaryKey("message_id", .text)
.references("messages", onDelete: .cascade)
t.column("embedding", .blob).notNull()
t.column("embedding_model", .text).notNull()
t.column("embedding_dimension", .integer).notNull()
t.column("created_at", .text).notNull()
}
// Conversation embeddings (aggregate of all messages)
try db.create(table: "conversation_embeddings") { t in
t.primaryKey("conversation_id", .text)
.references("conversations", onDelete: .cascade)
t.column("embedding", .blob).notNull()
t.column("embedding_model", .text).notNull()
t.column("embedding_dimension", .integer).notNull()
t.column("created_at", .text).notNull()
}
}
migrator.registerMigration("v8") { db in
// Conversation summaries for progressive summarization
try db.create(table: "conversation_summaries") { t in
t.primaryKey("id", .text)
t.column("conversation_id", .text).notNull()
.references("conversations", onDelete: .cascade)
t.column("start_message_index", .integer).notNull()
t.column("end_message_index", .integer).notNull()
t.column("summary", .text).notNull()
t.column("token_count", .integer)
t.column("created_at", .text).notNull()
t.column("summary_model", .text)
}
try db.create(
index: "idx_conversation_summaries_conv",
on: "conversation_summaries",
columns: ["conversation_id"]
)
}
migrator.registerMigration("v9") { db in
// Folders for organizing conversations
try db.create(table: "folders") { t in
t.primaryKey("id", .text)
t.column("name", .text).notNull()
t.column("sortOrder", .integer).notNull().defaults(to: 0)
t.column("createdAt", .text).notNull()
}
try db.alter(table: "conversations") { t in
t.add(column: "folderId", .text).references("folders", onDelete: .setNull)
}
}
migrator.registerMigration("v10") { db in
// Nested folders: a folder may live under another folder. ON DELETE RESTRICT (not
// CASCADE/SET NULL) is a defensive backstop — deleteFolder() always reparents
// children/conversations before deleting the row, in one transaction, so by the time
// DELETE runs nothing should reference it. RESTRICT throws loudly if that invariant
// is ever violated, instead of silently promoting things to top-level or cascading a
// delete through a whole subtree.
try db.alter(table: "folders") { t in
t.add(column: "parentId", .text)
.references("folders", onDelete: .restrict)
}
try db.create(index: "idx_folders_parentId", on: "folders", columns: ["parentId"])
}
migrator.registerMigration("v11") { db in
// Needed to resolve folder renames/reparents last-write-wins when Git Sync brings in
// folder state from another machine — without a timestamp there's no way to tell whose
// version of a rename is newer. Backfilled from createdAt for existing rows.
try db.alter(table: "folders") { t in
t.add(column: "updatedAt", .text)
}
try db.execute(sql: "UPDATE folders SET updatedAt = createdAt WHERE updatedAt IS NULL")
}
return migrator
}
// MARK: - Settings Operations
nonisolated func loadAllSettings() throws -> [String: String] {
try dbQueue.read { db in
let records = try SettingRecord.fetchAll(db)
return Dictionary(uniqueKeysWithValues: records.map { ($0.key, $0.value) })
}
}
nonisolated func setSetting(key: String, value: String) {
try? dbQueue.write { db in
let record = SettingRecord(key: key, value: value)
try record.save(db)
}
}
nonisolated func deleteSetting(key: String) {
try? dbQueue.write { db in
_ = try SettingRecord.deleteOne(db, key: key)
}
}
// MARK: - Encrypted Settings Operations
/// Store an encrypted setting (for sensitive data like API keys)
nonisolated func setEncryptedSetting(key: String, value: String) throws {
let encryptedValue = try EncryptionService.shared.encrypt(value)
try dbQueue.write { db in
let record = SettingRecord(key: "encrypted_\(key)", value: encryptedValue)
try record.save(db)
}
}
/// Retrieve and decrypt an encrypted setting
nonisolated func getEncryptedSetting(key: String) throws -> String? {
let encryptedValue = try dbQueue.read { db in
try SettingRecord.fetchOne(db, key: "encrypted_\(key)")?.value
}
guard let encryptedValue = encryptedValue else {
return nil
}
return try EncryptionService.shared.decrypt(encryptedValue)
}
/// Delete an encrypted setting
nonisolated func deleteEncryptedSetting(key: String) {
try? dbQueue.write { db in
_ = try SettingRecord.deleteOne(db, key: "encrypted_\(key)")
}
}
// MARK: - Conversation Operations
nonisolated func saveConversation(name: String, messages: [Message]) throws -> Conversation {
return try saveConversation(id: UUID(), name: name, messages: messages, primaryModel: nil)
}
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?, folderId: UUID? = nil) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
let now = Date()
let nowString = Self.isoString(from: now)
let convRecord = ConversationRecord(
id: id.uuidString,
name: name,
createdAt: nowString,
updatedAt: nowString,
primaryModel: primaryModel,
folderId: folderId?.uuidString
)
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
return MessageRecord(
id: UUID().uuidString,
conversationId: id.uuidString,
role: msg.role.rawValue,
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index,
modelId: msg.modelId
)
}
try dbQueue.write { db in
try convRecord.insert(db)
for record in messageRecords {
try record.insert(db)
}
}
let savedMessages = messages.filter { $0.role != .system }
return Conversation(
id: id,
name: name,
messages: savedMessages,
createdAt: now,
updatedAt: now,
primaryModel: primaryModel,
folderId: folderId
)
}
/// Update an existing conversation in-place: rename it, replace all its messages.
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
let nowString = Self.isoString(from: Date())
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
return MessageRecord(
id: UUID().uuidString,
conversationId: id.uuidString,
role: msg.role.rawValue,
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index,
modelId: msg.modelId
)
}
try dbQueue.write { db in
try db.execute(
sql: "UPDATE conversations SET name = ?, updatedAt = ?, primaryModel = ? WHERE id = ?",
arguments: [name, nowString, primaryModel, id.uuidString]
)
try db.execute(
sql: "DELETE FROM messages WHERE conversationId = ?",
arguments: [id.uuidString]
)
for record in messageRecords {
try record.insert(db)
}
}
}
nonisolated func loadConversation(id: UUID) throws -> (Conversation, [Message])? {
try dbQueue.read { db in
guard let convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
return nil
}
let messageRecords = try MessageRecord
.filter(Column("conversationId") == id.uuidString)
.order(Column("sortOrder"))
.fetchAll(db)
let messages = messageRecords.compactMap { record -> Message? in
guard let msgId = UUID(uuidString: record.id),
let role = MessageRole(rawValue: record.role),
let timestamp = Self.isoDate(from: record.timestamp)
else { return nil }
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
var message = Message(
id: msgId,
role: role,
content: record.content,
tokens: record.tokens,
cost: record.cost,
timestamp: timestamp,
modelId: record.modelId
)
message.isStarred = starred
return message
}
guard let convId = UUID(uuidString: convRecord.id),
let createdAt = Self.isoDate(from: convRecord.createdAt),
let updatedAt = Self.isoDate(from: convRecord.updatedAt)
else { return nil }
let conversation = Conversation(
id: convId,
name: convRecord.name,
messages: messages,
createdAt: createdAt,
updatedAt: updatedAt,
primaryModel: convRecord.primaryModel,
folderId: convRecord.folderId.flatMap { UUID(uuidString: $0) }
)
return (conversation, messages)
}
}
nonisolated func listConversations() throws -> [Conversation] {
try dbQueue.read { db in
let records = try ConversationRecord
.order(Column("updatedAt").desc)
.fetchAll(db)
return records.compactMap { record -> Conversation? in
guard let id = UUID(uuidString: record.id),
let createdAt = Self.isoDate(from: record.createdAt),
let updatedAt = Self.isoDate(from: record.updatedAt)
else { return nil }
// Fetch message count without loading all messages
let messageCount = (try? MessageRecord
.filter(Column("conversationId") == record.id)
.fetchCount(db)) ?? 0
// Get last message (for date + model fallback)
let lastMsg = try? MessageRecord
.filter(Column("conversationId") == record.id)
.order(Column("sortOrder").desc)
.fetchOne(db)
let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt
// Derive primary model: prefer the stored field, fall back to last message's modelId
let primaryModel = record.primaryModel ?? lastMsg?.modelId
// Create conversation with empty messages array but correct metadata
var conv = Conversation(
id: id,
name: record.name,
messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
createdAt: createdAt,
updatedAt: lastDate,
primaryModel: primaryModel,
folderId: record.folderId.flatMap { UUID(uuidString: $0) }
)
conv.updatedAt = lastDate
return conv
}
}
}
// MARK: - Folders
enum FolderError: Error, Sendable {
case wouldCreateCycle
}
nonisolated func createFolder(name: String, parentId: UUID? = nil) throws -> Folder {
let folder = Folder(name: name, sortOrder: try nextFolderSortOrder(), parentId: parentId)
let record = FolderRecord(
id: folder.id.uuidString,
name: folder.name,
sortOrder: folder.sortOrder,
createdAt: Self.isoString(from: folder.createdAt),
parentId: parentId?.uuidString,
updatedAt: Self.isoString(from: folder.updatedAt)
)
try dbQueue.write { db in
try record.insert(db)
}
return folder
}
private nonisolated func nextFolderSortOrder() throws -> Int {
try dbQueue.read { db in
let row = try Row.fetchOne(db, sql: "SELECT MAX(sortOrder) AS maxOrder FROM folders")
let maxOrder: Int? = row?["maxOrder"]
return (maxOrder ?? -1) + 1
}
}
nonisolated func renameFolder(id: UUID, name: String) throws {
try dbQueue.write { db in
try db.execute(
sql: "UPDATE folders SET name = ?, updatedAt = ? WHERE id = ?",
arguments: [name, Self.isoString(from: Date()), id.uuidString]
)
}
}
/// Reparents a folder (nil = promote to top level). Throws `.wouldCreateCycle` if `parentId`
/// is the folder itself or one of its own descendants.
nonisolated func moveFolder(id: UUID, toParent parentId: UUID?) throws {
try dbQueue.write { db in
if let parentId {
guard parentId != id else { throw FolderError.wouldCreateCycle }
let folders = try FolderRecord.fetchAll(db).compactMap(Self.folder(from:))
guard !Folder.isDescendant(parentId, of: id, in: folders) else {
throw FolderError.wouldCreateCycle
}
}
try db.execute(sql: "UPDATE folders SET parentId = ?, updatedAt = ? WHERE id = ?",
arguments: [parentId?.uuidString, Self.isoString(from: Date()), id.uuidString])
}
}
/// Creates or updates a folder with an externally-supplied identity and timestamps, for Git
/// Sync import — as opposed to `createFolder`, which is for user-initiated creation and always
/// mints a fresh id/timestamps. Last-write-wins: if the folder already exists locally, only
/// overwrites name/parentId when `updatedAt` is strictly newer than the local row's.
nonisolated func upsertSyncedFolder(id: UUID, name: String, parentId: UUID?, createdAt: Date, updatedAt: Date) throws {
let existing = try dbQueue.read { db in
try FolderRecord.fetchOne(db, key: id.uuidString)
}
if let existing {
guard let existingUpdatedAt = existing.updatedAt.flatMap(Self.isoDate(from:)),
existingUpdatedAt < updatedAt
else { return }
try dbQueue.write { db in
try db.execute(
sql: "UPDATE folders SET name = ?, parentId = ?, updatedAt = ? WHERE id = ?",
arguments: [name, parentId?.uuidString, Self.isoString(from: updatedAt), id.uuidString]
)
}
} else {
let record = FolderRecord(
id: id.uuidString, name: name, sortOrder: try nextFolderSortOrder(),
createdAt: Self.isoString(from: createdAt), parentId: parentId?.uuidString,
updatedAt: Self.isoString(from: updatedAt)
)
try dbQueue.write { db in
try record.insert(db)
}
}
}
/// Deletes a folder, reparenting everything directly inside it (child folders + conversations
/// filed directly in it) up one level to the deleted folder's own parent. Conversations are
/// never deleted. All statements run in one transaction, satisfying the ON DELETE RESTRICT
/// backstop (reparent happens before the DELETE).
nonisolated func deleteFolder(id: UUID) throws {
try dbQueue.write { db in
guard let ownRecord = try FolderRecord.fetchOne(db, key: id.uuidString) else { return }
let parentIdString = ownRecord.parentId
try db.execute(sql: "UPDATE folders SET parentId = ? WHERE parentId = ?",
arguments: [parentIdString, id.uuidString])
try db.execute(sql: "UPDATE conversations SET folderId = ? WHERE folderId = ?",
arguments: [parentIdString, id.uuidString])
_ = try FolderRecord.deleteOne(db, key: id.uuidString)
}
}
nonisolated func listFolders() throws -> [Folder] {
try dbQueue.read { db in
// Alphabetical, not creation order (sortOrder) — folders should sort
// predictably by name everywhere they're listed.
let records = try FolderRecord.fetchAll(db, sql: "SELECT * FROM folders ORDER BY name COLLATE NOCASE")
return records.compactMap(Self.folder(from:))
}
}
private nonisolated static func folder(from record: FolderRecord) -> Folder? {
guard let id = UUID(uuidString: record.id),
let createdAt = Self.isoDate(from: record.createdAt)
else { return nil }
// Falls back to createdAt if updatedAt is somehow missing (shouldn't happen post-v11
// migration, which backfills every existing row) rather than failing the whole fetch.
let updatedAt = record.updatedAt.flatMap(Self.isoDate(from:)) ?? createdAt
return Folder(
id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt,
parentId: record.parentId.flatMap { UUID(uuidString: $0) }, updatedAt: updatedAt
)
}
nonisolated func moveConversation(id: UUID, toFolder folderId: UUID?) throws {
try dbQueue.write { db in
try db.execute(
sql: "UPDATE conversations SET folderId = ? WHERE id = ?",
arguments: [folderId?.uuidString, id.uuidString]
)
}
}
// MARK: - Usage Statistics
nonisolated func getOverallUsageStats() throws -> UsageStats {
try dbQueue.read { db in
guard let row = try Row.fetchOne(db, sql: """
SELECT COUNT(*) AS cnt,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MIN(timestamp) AS minTs,
MAX(timestamp) AS maxTs
FROM messages
""")
else {
return UsageStats()
}
let costCount: Int = row["costCount"]
let minTs: String? = row["minTs"]
let maxTs: String? = row["maxTs"]
return UsageStats(
totalMessages: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) },
lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) }
)
}
}
nonisolated func getUsageByModel() throws -> [ModelUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
SELECT modelId,
COUNT(*) AS cnt,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MAX(timestamp) AS lastUsed
FROM messages
WHERE modelId IS NOT NULL
GROUP BY modelId
""")
let stats: [ModelUsageStat] = rows.compactMap { row in
guard let modelId: String = row["modelId"],
let lastUsedString: String = row["lastUsed"],
let lastUsed = Self.isoDate(from: lastUsedString)
else { return nil }
let costCount: Int = row["costCount"]
return ModelUsageStat(
modelId: modelId,
messageCount: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
lastUsed: lastUsed
)
}
return stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
}
}
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
SELECT m.conversationId AS conversationId,
c.name AS name,
COUNT(*) AS cnt,
COALESCE(SUM(m.tokens), 0) AS tokens,
COALESCE(SUM(m.cost), 0) AS cost,
COUNT(m.cost) AS costCount
FROM messages m
JOIN conversations c ON m.conversationId = c.id
GROUP BY m.conversationId
""")
let stats: [ConversationUsageStat] = rows.compactMap { row in
guard let conversationIdString: String = row["conversationId"],
let conversationId = UUID(uuidString: conversationIdString)
else { return nil }
let costCount: Int = row["costCount"]
return ConversationUsageStat(
conversationId: conversationId,
name: row["name"],
messageCount: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0
)
}
let sorted = stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
return Array(sorted.prefix(limit))
}
}
nonisolated func deleteConversation(id: UUID) throws -> Bool {
Log.db.info("Deleting conversation \(id.uuidString)")
return try dbQueue.write { db in
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: id.uuidString)
}
}
nonisolated func deleteConversation(name: String) throws -> Bool {
try dbQueue.write { db in
guard let record = try ConversationRecord
.filter(Column("name") == name)
.fetchOne(db)
else { return false }
try MessageRecord.filter(Column("conversationId") == record.id).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: record.id)
}
}
nonisolated func updateConversation(id: UUID, name: String?, messages: [Message]?) throws -> Bool {
try dbQueue.write { db in
guard var convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
return false
}
if let name = name {
convRecord.name = name
}
convRecord.updatedAt = Self.isoString(from: Date())
try convRecord.update(db)
if let messages = messages {
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
return MessageRecord(
id: UUID().uuidString,
conversationId: id.uuidString,
role: msg.role.rawValue,
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index
)
}
for record in messageRecords {
try record.insert(db)
}
}
return true
}
}
// MARK: - Command History Operations
nonisolated func saveCommandHistory(input: String) {
let now = Date()
let record = HistoryRecord(
id: UUID().uuidString,
input: input,
timestamp: Self.isoString(from: now)
)
try? dbQueue.write { db in
try record.insert(db)
// Clean up old entries if we exceed the limit
let count = try HistoryRecord.fetchCount(db)
if count > Self.maxHistoryEntries {
// Delete oldest entries to get back to limit
let excess = count - Self.maxHistoryEntries
try db.execute(
sql: """
DELETE FROM command_history
WHERE id IN (
SELECT id FROM command_history
ORDER BY timestamp ASC
LIMIT ?
)
""",
arguments: [excess]
)
}
}
}
nonisolated func loadCommandHistory() throws -> [(input: String, timestamp: Date)] {
try dbQueue.read { db in
let records = try HistoryRecord
.order(Column("timestamp").desc)
.fetchAll(db)
return records.compactMap { record in
guard let date = Self.isoDate(from: record.timestamp) else {
return nil
}
return (input: record.input, timestamp: date)
}
}
}
nonisolated func searchCommandHistory(query: String) throws -> [(input: String, timestamp: Date)] {
try dbQueue.read { db in
let records = try HistoryRecord
.filter(Column("input").like("%\(query)%"))
.order(Column("timestamp").desc)
.fetchAll(db)
return records.compactMap { record in
guard let date = Self.isoDate(from: record.timestamp) else {
return nil
}
return (input: record.input, timestamp: date)
}
}
}
// MARK: - Email Log Operations
nonisolated func saveEmailLog(_ log: EmailLog) {
let record = EmailLogRecord(
id: log.id.uuidString,
timestamp: Self.isoString(from: log.timestamp),
sender: log.sender,
subject: log.subject,
emailContent: log.emailContent,
aiResponse: log.aiResponse,
status: log.status.rawValue,
errorMessage: log.errorMessage,
tokens: log.tokens,
cost: log.cost,
responseTime: log.responseTime,
modelId: log.modelId
)
try? dbQueue.write { db in
try record.insert(db)
}
}
nonisolated func loadEmailLogs(limit: Int = 100) throws -> [EmailLog] {
try dbQueue.read { db in
let records = try EmailLogRecord
.order(Column("timestamp").desc)
.limit(limit)
.fetchAll(db)
return records.compactMap { record in
guard let timestamp = Self.isoDate(from: record.timestamp),
let status = EmailLogStatus(rawValue: record.status),
let id = UUID(uuidString: record.id) else {
return nil
}
return EmailLog(
id: id,
timestamp: timestamp,
sender: record.sender,
subject: record.subject,
emailContent: record.emailContent,
aiResponse: record.aiResponse,
status: status,
errorMessage: record.errorMessage,
tokens: record.tokens,
cost: record.cost,
responseTime: record.responseTime,
modelId: record.modelId
)
}
}
}
nonisolated func deleteEmailLog(id: UUID) {
try? dbQueue.write { db in
try db.execute(
sql: "DELETE FROM email_logs WHERE id = ?",
arguments: [id.uuidString]
)
}
}
nonisolated func clearEmailLogs() {
try? dbQueue.write { db in
try db.execute(sql: "DELETE FROM email_logs")
}
}
nonisolated func getEmailLogCount() throws -> Int {
try dbQueue.read { db in
try EmailLogRecord.fetchCount(db)
}
}
// MARK: - Message Metadata Operations
nonisolated func getMessageMetadata(messageId: UUID) throws -> MessageMetadataRecord? {
try dbQueue.read { db in
try MessageMetadataRecord.fetchOne(db, key: messageId.uuidString)
}
}
nonisolated func setMessageStarred(messageId: UUID, starred: Bool) throws {
try dbQueue.write { db in
// Check if metadata exists
if var existing = try MessageMetadataRecord.fetchOne(db, key: messageId.uuidString) {
existing.user_starred = starred ? 1 : 0
try existing.update(db)
} else {
// Create new metadata record
let record = MessageMetadataRecord(
message_id: messageId.uuidString,
importance_score: 0.0,
user_starred: starred ? 1 : 0,
summary: nil,
chunk_index: 0
)
try record.insert(db)
}
}
}
nonisolated func setMessageImportance(messageId: UUID, score: Double) throws {
try dbQueue.write { db in
if var existing = try MessageMetadataRecord.fetchOne(db, key: messageId.uuidString) {
existing.importance_score = score
try existing.update(db)
} else {
let record = MessageMetadataRecord(
message_id: messageId.uuidString,
importance_score: score,
user_starred: 0,
summary: nil,
chunk_index: 0
)
try record.insert(db)
}
}
}
nonisolated func getStarredMessages(conversationId: UUID) throws -> [String] {
try dbQueue.read { db in
let sql = """
SELECT mm.message_id
FROM message_metadata mm
JOIN messages m ON m.id = mm.message_id
WHERE m.conversationId = ? AND mm.user_starred = 1
ORDER BY m.sortOrder
"""
return try String.fetchAll(db, sql: sql, arguments: [conversationId.uuidString])
}
}
// MARK: - Embedding Operations
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = Self.isoString(from: Date())
let record = MessageEmbeddingRecord(
message_id: messageId.uuidString,
embedding: embedding,
embedding_model: model,
embedding_dimension: dimension,
created_at: now
)
try dbQueue.write { db in
try record.save(db)
}
}
nonisolated func getMessageEmbedding(messageId: UUID) throws -> Data? {
try dbQueue.read { db in
try MessageEmbeddingRecord.fetchOne(db, key: messageId.uuidString)?.embedding
}
}
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = Self.isoString(from: Date())
let record = ConversationEmbeddingRecord(
conversation_id: conversationId.uuidString,
embedding: embedding,
embedding_model: model,
embedding_dimension: dimension,
created_at: now
)
try dbQueue.write { db in
try record.save(db)
}
}
nonisolated func getConversationEmbedding(conversationId: UUID) throws -> Data? {
try dbQueue.read { db in
try ConversationEmbeddingRecord.fetchOne(db, key: conversationId.uuidString)?.embedding
}
}
nonisolated func getAllConversationEmbeddings() throws -> [(UUID, Data)] {
try dbQueue.read { db in
let records = try ConversationEmbeddingRecord.fetchAll(db)
return records.compactMap { record in
guard let id = UUID(uuidString: record.conversation_id) else { return nil }
return (id, record.embedding)
}
}
}
/// Search conversations by semantic similarity
nonisolated func searchConversationsBySemantic(queryEmbedding: [Float], limit: Int = 10) throws -> [(Conversation, Float)] {
// Get all conversations
let allConversations = try listConversations()
// Get all conversation embeddings
let embeddingData = try getAllConversationEmbeddings()
let embeddingMap = Dictionary(uniqueKeysWithValues: embeddingData)
// Calculate similarity scores
var results: [(Conversation, Float)] = []
for conv in allConversations {
guard let embeddingData = embeddingMap[conv.id] else { continue }
// Deserialize embedding
let embedding = deserializeEmbedding(embeddingData)
// Calculate cosine similarity
let similarity = EmbeddingService.shared.cosineSimilarity(queryEmbedding, embedding)
results.append((conv, similarity))
}
// Sort by similarity (highest first) and take top N
results.sort { $0.1 > $1.1 }
return Array(results.prefix(limit))
}
private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
var embedding: [Float] = []
embedding.reserveCapacity(data.count / 4)
for offset in stride(from: 0, to: data.count, by: 4) {
let bytes = data.subdata(in: offset..<(offset + 4))
let bitPattern = bytes.withUnsafeBytes { $0.load(as: UInt32.self) }
let value = Float(bitPattern: UInt32(littleEndian: bitPattern))
embedding.append(value)
}
return embedding
}
// MARK: - Conversation Summary Operations
nonisolated func saveConversationSummary(
conversationId: UUID,
startIndex: Int,
endIndex: Int,
summary: String,
model: String?,
tokenCount: Int?
) throws {
let now = Self.isoString(from: Date())
let record = ConversationSummaryRecord(
id: UUID().uuidString,
conversation_id: conversationId.uuidString,
start_message_index: startIndex,
end_message_index: endIndex,
summary: summary,
token_count: tokenCount,
created_at: now,
summary_model: model
)
try dbQueue.write { db in
try record.insert(db)
}
}
nonisolated func getConversationSummaries(conversationId: UUID) throws -> [ConversationSummaryRecord] {
try dbQueue.read { db in
try ConversationSummaryRecord
.filter(Column("conversation_id") == conversationId.uuidString)
.order(Column("start_message_index"))
.fetchAll(db)
}
}
nonisolated func hasSummaryForRange(conversationId: UUID, startIndex: Int, endIndex: Int) throws -> Bool {
try dbQueue.read { db in
let count = try ConversationSummaryRecord
.filter(Column("conversation_id") == conversationId.uuidString)
.filter(Column("start_message_index") == startIndex)
.filter(Column("end_message_index") == endIndex)
.fetchCount(db)
return count > 0
}
}
}