From a4f0761f25801ae0314583e65756b2eaf7ec67d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 08:51:15 +0100 Subject: [PATCH] add v0.4 models, schema, query extensions for GTD tasks, labels, deferrals New model types: TaskStatus, TaskSummary, LabelInfo, ItemSummary. GRDB records: TaskRecord, LabelRecord, ItemLabelRecord, DeferralRecord. Database migrations v3_task, v3_label, v3_deferral with indexes. MailStore query extensions for labels, item-labels, deferrals, task cache. TaskStore module wrapping MailStore. Tests for all v3 tables and records. Co-Authored-By: Claude Opus 4.6 (1M context) --- Packages/MagnumOpusCore/Package.swift | 12 +- .../Sources/MailStore/DatabaseSetup.swift | 59 +++ .../Sources/MailStore/MailStore.swift | 261 +++++++++++++ .../MailStore/Records/DeferralRecord.swift | 25 ++ .../MailStore/Records/ItemLabelRecord.swift | 15 + .../MailStore/Records/LabelRecord.swift | 25 ++ .../MailStore/Records/TaskRecord.swift | 50 +++ .../Sources/Models/ItemSummary.swift | 48 +++ .../Sources/Models/LabelInfo.swift | 13 + .../Sources/Models/TaskStatus.swift | 6 + .../Sources/Models/TaskSummary.swift | 36 ++ .../Sources/TaskStore/TaskStore.swift | 13 + .../MailStoreTests/LabelDeferralTests.swift | 350 ++++++++++++++++++ .../Tests/TaskStoreTests/TaskStoreTests.swift | 13 + 14 files changed, 925 insertions(+), 1 deletion(-) create mode 100644 Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift create mode 100644 Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift create mode 100644 Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift create mode 100644 Packages/MagnumOpusCore/Sources/MailStore/Records/TaskRecord.swift create mode 100644 Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift create mode 100644 Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift create mode 100644 Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift create mode 100644 Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift create mode 100644 Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift create mode 100644 Packages/MagnumOpusCore/Tests/MailStoreTests/LabelDeferralTests.swift create mode 100644 Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift diff --git a/Packages/MagnumOpusCore/Package.swift b/Packages/MagnumOpusCore/Package.swift index b0571db..498f966 100644 --- a/Packages/MagnumOpusCore/Package.swift +++ b/Packages/MagnumOpusCore/Package.swift @@ -11,6 +11,7 @@ let package = Package( products: [ .library(name: "Models", targets: ["Models"]), .library(name: "MailStore", targets: ["MailStore"]), + .library(name: "TaskStore", targets: ["TaskStore"]), .library(name: "IMAPClient", targets: ["IMAPClient"]), .library(name: "SMTPClient", targets: ["SMTPClient"]), .library(name: "SyncEngine", targets: ["SyncEngine"]), @@ -46,9 +47,17 @@ let package = Package( .product(name: "NIOSSL", package: "swift-nio-ssl"), ] ), + .target( + name: "TaskStore", + dependencies: [ + "Models", + "MailStore", + .product(name: "GRDB", package: "GRDB.swift"), + ] + ), .target( name: "SyncEngine", - dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore"] + dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore"] ), .testTarget(name: "ModelsTests", dependencies: ["Models"]), .testTarget(name: "MailStoreTests", dependencies: ["MailStore"]), @@ -59,6 +68,7 @@ let package = Package( ] ), .testTarget(name: "SMTPClientTests", dependencies: ["SMTPClient", "Models"]), + .testTarget(name: "TaskStoreTests", dependencies: ["TaskStore", "MailStore"]), .testTarget(name: "SyncEngineTests", dependencies: ["SyncEngine", "IMAPClient", "MailStore"]), ] ) diff --git a/Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift b/Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift index 2cbd5d6..de24086 100644 --- a/Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift +++ b/Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift @@ -127,6 +127,65 @@ public enum DatabaseSetup { try db.create(index: "idx_pendingAction_createdAt", on: "pendingAction", columns: ["createdAt"]) } + migrator.registerMigration("v3_task") { db in + try db.create(table: "task") { t in + t.primaryKey("id", .text) + t.belongsTo("account", onDelete: .cascade).notNull() + t.column("summary", .text).notNull() + t.column("description", .text) + t.column("status", .text).notNull().defaults(to: "NEEDS-ACTION") + t.column("priority", .integer).notNull().defaults(to: 0) + t.column("dueDate", .text) + t.column("deferUntil", .text) + t.column("createdAt", .text).notNull() + t.column("updatedAt", .text).notNull() + t.column("filePath", .text) + t.column("linkedMessageId", .text) + t.column("isSomeday", .boolean).notNull().defaults(to: false) + } + try db.create(index: "idx_task_status", on: "task", columns: ["status"]) + try db.create(index: "idx_task_deferUntil", on: "task", columns: ["deferUntil"]) + try db.create(index: "idx_task_dueDate", on: "task", columns: ["dueDate"]) + try db.create(index: "idx_task_linkedMessageId", on: "task", columns: ["linkedMessageId"]) + } + + migrator.registerMigration("v3_label") { db in + try db.create(table: "label") { t in + t.primaryKey("id", .text) + t.belongsTo("account", onDelete: .cascade).notNull() + t.column("name", .text).notNull() + t.column("isProject", .boolean).notNull().defaults(to: false) + t.column("color", .text) + } + try db.create( + index: "idx_label_accountId_name", + on: "label", + columns: ["accountId", "name"], + unique: true + ) + + try db.create(table: "itemLabel") { t in + t.column("labelId", .text).notNull() + .references("label", onDelete: .cascade) + t.column("itemType", .text).notNull() + t.column("itemId", .text).notNull() + t.primaryKey(["labelId", "itemType", "itemId"]) + } + } + + migrator.registerMigration("v3_deferral") { db in + try db.create(table: "deferral") { t in + t.primaryKey("id", .text) + t.column("messageId", .text).notNull() + .references("message", column: "id", onDelete: .cascade) + t.column("deferUntil", .text) + t.column("originalMailbox", .text) + t.column("createdAt", .text).notNull() + t.uniqueKey(["messageId"]) + } + try db.create(index: "idx_deferral_deferUntil", on: "deferral", columns: ["deferUntil"]) + } + return migrator } diff --git a/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift b/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift index 3bc01a8..8e9cd33 100644 --- a/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift +++ b/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift @@ -316,4 +316,265 @@ public final class MailStore: Sendable { public var databaseReader: any DatabaseReader { dbWriter } + + /// Access the underlying database writer (for TaskStore and other modules) + public var databaseWriter: any DatabaseWriter { + dbWriter + } + + // MARK: - Labels + + public func insertLabel(_ label: LabelRecord) throws { + try dbWriter.write { db in + try label.insert(db) + } + } + + public func labels(accountId: String) throws -> [LabelRecord] { + try dbWriter.read { db in + try LabelRecord + .filter(Column("accountId") == accountId) + .order(Column("name")) + .fetchAll(db) + } + } + + public func projectLabels(accountId: String) throws -> [LabelRecord] { + try dbWriter.read { db in + try LabelRecord + .filter(Column("accountId") == accountId) + .filter(Column("isProject") == true) + .order(Column("name")) + .fetchAll(db) + } + } + + public func deleteLabel(id: String) throws { + try dbWriter.write { db in + _ = try LabelRecord.deleteOne(db, key: id) + } + } + + public func labelByName(_ name: String, accountId: String) throws -> LabelRecord? { + try dbWriter.read { db in + try LabelRecord + .filter(Column("accountId") == accountId) + .filter(Column("name") == name) + .fetchOne(db) + } + } + + // MARK: - Item Labels + + public func attachLabel(labelId: String, itemType: String, itemId: String) throws { + try dbWriter.write { db in + try ItemLabelRecord(labelId: labelId, itemType: itemType, itemId: itemId).save(db) + } + } + + public func detachLabel(labelId: String, itemType: String, itemId: String) throws { + try dbWriter.write { db in + try db.execute( + sql: "DELETE FROM itemLabel WHERE labelId = ? AND itemType = ? AND itemId = ?", + arguments: [labelId, itemType, itemId] + ) + } + } + + public func labelsForItem(itemType: String, itemId: String) throws -> [LabelRecord] { + try dbWriter.read { db in + try LabelRecord.fetchAll(db, sql: """ + SELECT l.* FROM label l + JOIN itemLabel il ON il.labelId = l.id + WHERE il.itemType = ? AND il.itemId = ? + ORDER BY l.name + """, arguments: [itemType, itemId]) + } + } + + public func itemsWithLabel(labelId: String) throws -> [ItemLabelRecord] { + try dbWriter.read { db in + try ItemLabelRecord + .filter(Column("labelId") == labelId) + .fetchAll(db) + } + } + + // MARK: - Deferrals (email only) + + public func insertDeferral(_ deferral: DeferralRecord) throws { + try dbWriter.write { db in + try deferral.insert(db) + } + } + + public func deleteDeferral(id: String) throws { + try dbWriter.write { db in + _ = try DeferralRecord.deleteOne(db, key: id) + } + } + + public func deleteDeferralForMessage(messageId: String) throws { + try dbWriter.write { db in + try db.execute( + sql: "DELETE FROM deferral WHERE messageId = ?", + arguments: [messageId] + ) + } + } + + public func deferralForMessage(messageId: String) throws -> DeferralRecord? { + try dbWriter.read { db in + try DeferralRecord + .filter(Column("messageId") == messageId) + .fetchOne(db) + } + } + + public func expiredDeferrals(beforeDate: String) throws -> [DeferralRecord] { + try dbWriter.read { db in + try DeferralRecord + .filter(Column("deferUntil") != nil) + .filter(Column("deferUntil") <= beforeDate) + .fetchAll(db) + } + } + + public func somedayDeferrals() throws -> [DeferralRecord] { + try dbWriter.read { db in + try DeferralRecord + .filter(Column("deferUntil") == nil) + .fetchAll(db) + } + } + + // MARK: - Task cache queries + + public func insertTask(_ task: TaskRecord) throws { + try dbWriter.write { db in + try task.insert(db) + } + } + + public func updateTask(_ task: TaskRecord) throws { + try dbWriter.write { db in + try task.update(db) + } + } + + public func deleteTask(id: String) throws { + try dbWriter.write { db in + _ = try TaskRecord.deleteOne(db, key: id) + } + } + + public func task(id: String) throws -> TaskRecord? { + try dbWriter.read { db in + try TaskRecord.fetchOne(db, key: id) + } + } + + public func inboxTasks(accountId: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord + .filter(Column("accountId") == accountId) + .filter(Column("status") == "NEEDS-ACTION") + .filter(Column("deferUntil") == nil) + .filter(Column("isSomeday") == false) + .order(Column("createdAt").desc) + .fetchAll(db) + } + } + + public func todayTasks(accountId: String, todayDate: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord + .filter(Column("accountId") == accountId) + .filter(Column("status") == "NEEDS-ACTION" || Column("status") == "IN-PROCESS") + .filter(Column("dueDate") != nil) + .filter(Column("dueDate") <= todayDate) + .filter(Column("isSomeday") == false) + .order(Column("priority").desc, Column("dueDate").asc) + .fetchAll(db) + } + } + + public func upcomingTasks(accountId: String, afterDate: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord + .filter(Column("accountId") == accountId) + .filter(Column("status") == "NEEDS-ACTION" || Column("status") == "IN-PROCESS") + .filter(Column("dueDate") != nil) + .filter(Column("dueDate") > afterDate) + .filter(Column("isSomeday") == false) + .order(Column("dueDate").asc) + .fetchAll(db) + } + } + + public func somedayTasks(accountId: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord + .filter(Column("accountId") == accountId) + .filter(Column("isSomeday") == true) + .filter(Column("status") == "NEEDS-ACTION") + .order(Column("createdAt").desc) + .fetchAll(db) + } + } + + public func archivedTasks(accountId: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord + .filter(Column("accountId") == accountId) + .filter(Column("status") == "COMPLETED" || Column("status") == "CANCELLED") + .order(Column("updatedAt").desc) + .fetchAll(db) + } + } + + public func tasksLinkedToThread(threadId: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord.fetchAll(db, sql: """ + SELECT t.* FROM task t + WHERE t.linkedMessageId IN ( + SELECT m.messageId FROM message m + JOIN threadMessage tm ON tm.messageId = m.id + WHERE tm.threadId = ? + ) + ORDER BY t.createdAt ASC + """, arguments: [threadId]) + } + } + + public func expiredDeferredTasks(beforeDate: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord + .filter(Column("deferUntil") != nil) + .filter(Column("deferUntil") <= beforeDate) + .filter(Column("status") == "NEEDS-ACTION") + .fetchAll(db) + } + } + + public func clearTaskDeferral(id: String) throws { + try dbWriter.write { db in + try db.execute( + sql: "UPDATE task SET deferUntil = NULL WHERE id = ?", + arguments: [id] + ) + } + } + + public func deleteAllTasks() throws { + try dbWriter.write { db in + _ = try TaskRecord.deleteAll(db) + } + } + + public func deleteAllItemLabelsForTasks() throws { + try dbWriter.write { db in + try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'") + } + } } diff --git a/Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift b/Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift new file mode 100644 index 0000000..21c93f1 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift @@ -0,0 +1,25 @@ +import GRDB + +public struct DeferralRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "deferral" + + public var id: String + public var messageId: String + public var deferUntil: String? + public var originalMailbox: String? + public var createdAt: String + + public init( + id: String, + messageId: String, + deferUntil: String? = nil, + originalMailbox: String? = nil, + createdAt: String + ) { + self.id = id + self.messageId = messageId + self.deferUntil = deferUntil + self.originalMailbox = originalMailbox + self.createdAt = createdAt + } +} diff --git a/Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift b/Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift new file mode 100644 index 0000000..f1e3bbf --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift @@ -0,0 +1,15 @@ +import GRDB + +public struct ItemLabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "itemLabel" + + public var labelId: String + public var itemType: String + public var itemId: String + + public init(labelId: String, itemType: String, itemId: String) { + self.labelId = labelId + self.itemType = itemType + self.itemId = itemId + } +} diff --git a/Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift b/Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift new file mode 100644 index 0000000..1d81a82 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift @@ -0,0 +1,25 @@ +import GRDB + +public struct LabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "label" + + public var id: String + public var accountId: String + public var name: String + public var isProject: Bool + public var color: String? + + public init( + id: String, + accountId: String, + name: String, + isProject: Bool = false, + color: String? = nil + ) { + self.id = id + self.accountId = accountId + self.name = name + self.isProject = isProject + self.color = color + } +} diff --git a/Packages/MagnumOpusCore/Sources/MailStore/Records/TaskRecord.swift b/Packages/MagnumOpusCore/Sources/MailStore/Records/TaskRecord.swift new file mode 100644 index 0000000..88f0efe --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/MailStore/Records/TaskRecord.swift @@ -0,0 +1,50 @@ +import Foundation +import GRDB + +public struct TaskRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "task" + + public var id: String + public var accountId: String + public var summary: String + public var description: String? + public var status: String + public var priority: Int + public var dueDate: String? + public var deferUntil: String? + public var createdAt: String + public var updatedAt: String + public var filePath: String? + public var linkedMessageId: String? + public var isSomeday: Bool + + public init( + id: String, + accountId: String, + summary: String, + description: String? = nil, + status: String = "NEEDS-ACTION", + priority: Int = 0, + dueDate: String? = nil, + deferUntil: String? = nil, + createdAt: String, + updatedAt: String, + filePath: String? = nil, + linkedMessageId: String? = nil, + isSomeday: Bool = false + ) { + self.id = id + self.accountId = accountId + self.summary = summary + self.description = description + self.status = status + self.priority = priority + self.dueDate = dueDate + self.deferUntil = deferUntil + self.createdAt = createdAt + self.updatedAt = updatedAt + self.filePath = filePath + self.linkedMessageId = linkedMessageId + self.isSomeday = isSomeday + } +} diff --git a/Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift b/Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift new file mode 100644 index 0000000..271a7dc --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift @@ -0,0 +1,48 @@ +import Foundation + +public enum ItemSummary: Sendable, Identifiable, Equatable { + case email(MessageSummary) + case task(TaskSummary) + + public var id: String { + switch self { + case .email(let msg): "email-\(msg.id)" + case .task(let task): "task-\(task.id)" + } + } + + public var title: String { + switch self { + case .email(let msg): msg.subject ?? "(no subject)" + case .task(let task): task.summary + } + } + + public var date: Date { + switch self { + case .email(let msg): msg.date + case .task(let task): task.createdAt + } + } + + public var dueDate: Date? { + switch self { + case .email: nil + case .task(let task): task.dueDate + } + } + + public var isDeferred: Bool { + switch self { + case .email: false + case .task(let task): task.deferUntil != nil + } + } + + public var isSomeday: Bool { + switch self { + case .email: false + case .task(let task): task.isSomeday + } + } +} diff --git a/Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift b/Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift new file mode 100644 index 0000000..b002a03 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift @@ -0,0 +1,13 @@ +public struct LabelInfo: Sendable, Identifiable, Equatable { + public var id: String + public var name: String + public var isProject: Bool + public var color: String? + + public init(id: String, name: String, isProject: Bool = false, color: String? = nil) { + self.id = id + self.name = name + self.isProject = isProject + self.color = color + } +} diff --git a/Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift b/Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift new file mode 100644 index 0000000..b9d0b65 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift @@ -0,0 +1,6 @@ +public enum TaskStatus: String, Sendable, Codable { + case needsAction = "NEEDS-ACTION" + case inProcess = "IN-PROCESS" + case completed = "COMPLETED" + case cancelled = "CANCELLED" +} diff --git a/Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift b/Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift new file mode 100644 index 0000000..915489c --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct TaskSummary: Sendable, Identifiable, Equatable { + public var id: String + public var accountId: String + public var summary: String + public var description: String? + public var status: TaskStatus + public var priority: Int + public var dueDate: Date? + public var deferUntil: Date? + public var createdAt: Date + public var linkedMessageId: String? + public var categories: [String] + public var isSomeday: Bool + + public init( + id: String, accountId: String, summary: String, description: String? = nil, + status: TaskStatus = .needsAction, priority: Int = 0, dueDate: Date? = nil, + deferUntil: Date? = nil, createdAt: Date = Date(), linkedMessageId: String? = nil, + categories: [String] = [], isSomeday: Bool = false + ) { + self.id = id + self.accountId = accountId + self.summary = summary + self.description = description + self.status = status + self.priority = priority + self.dueDate = dueDate + self.deferUntil = deferUntil + self.createdAt = createdAt + self.linkedMessageId = linkedMessageId + self.categories = categories + self.isSomeday = isSomeday + } +} diff --git a/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift b/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift new file mode 100644 index 0000000..598ba29 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift @@ -0,0 +1,13 @@ +@_exported import MailStore +import Models + +/// TaskStore provides task-specific business logic on top of MailStore's +/// database records and queries. The underlying TaskRecord and its CRUD +/// operations live in MailStore to share the same database connection. +public struct TaskStore: Sendable { + public let mailStore: MailStore + + public init(mailStore: MailStore) { + self.mailStore = mailStore + } +} diff --git a/Packages/MagnumOpusCore/Tests/MailStoreTests/LabelDeferralTests.swift b/Packages/MagnumOpusCore/Tests/MailStoreTests/LabelDeferralTests.swift new file mode 100644 index 0000000..a4267cf --- /dev/null +++ b/Packages/MagnumOpusCore/Tests/MailStoreTests/LabelDeferralTests.swift @@ -0,0 +1,350 @@ +import Testing +import GRDB +@testable import MailStore + +@Suite("V3 Labels, Deferrals & Tasks") +struct LabelDeferralTests { + func makeStore() throws -> MailStore { + try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase()) + } + + func makeAccount(_ store: MailStore) throws { + try store.insertAccount(AccountRecord( + id: "acc1", name: "Test", email: "test@example.com", + imapHost: "imap.example.com", imapPort: 993 + )) + } + + func makeMailboxAndMessage(_ store: MailStore) throws { + try store.upsertMailbox(MailboxRecord( + id: "mb1", accountId: "acc1", name: "INBOX", + uidValidity: 1, uidNext: 100 + )) + try store.insertMessages([ + MessageRecord( + id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1, + messageId: "msg1@example.com", inReplyTo: nil, refs: nil, + subject: "Test", fromAddress: "alice@example.com", fromName: "Alice", + toAddresses: nil, ccAddresses: nil, + date: "2026-03-14T10:00:00Z", snippet: nil, + bodyText: nil, bodyHtml: nil, + isRead: false, isFlagged: false, size: 100 + ), + ]) + } + + // MARK: - Schema verification + + @Test("v3 tables exist after migration") + func v3TablesExist() throws { + let store = try makeStore() + let tables = try store.databaseWriter.read { db in + try String.fetchAll(db, sql: """ + SELECT name FROM sqlite_master WHERE type='table' + AND name IN ('task', 'label', 'itemLabel', 'deferral') + ORDER BY name + """) + } + #expect(tables == ["deferral", "itemLabel", "label", "task"]) + } + + @Test("task table has expected columns") + func taskColumns() throws { + let store = try makeStore() + let columns = try store.databaseWriter.read { db in + try db.columns(in: "task").map(\.name) + } + let expected = [ + "id", "accountId", "summary", "description", "status", + "priority", "dueDate", "deferUntil", "createdAt", "updatedAt", + "filePath", "linkedMessageId", "isSomeday", + ] + for col in expected { + #expect(columns.contains(col), "missing column: \(col)") + } + } + + // MARK: - LabelRecord round-trip + + @Test("LabelRecord insert and fetch round-trip") + func labelRoundTrip() throws { + let store = try makeStore() + try makeAccount(store) + + let label = LabelRecord( + id: "lbl1", accountId: "acc1", name: "urgent", + isProject: false, color: "#ff0000" + ) + try store.insertLabel(label) + + let labels = try store.labels(accountId: "acc1") + #expect(labels.count == 1) + #expect(labels[0].name == "urgent") + #expect(labels[0].color == "#ff0000") + #expect(labels[0].isProject == false) + } + + @Test("labelByName finds label by account and name") + func labelByName() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertLabel(LabelRecord( + id: "lbl1", accountId: "acc1", name: "work" + )) + + let found = try store.labelByName("work", accountId: "acc1") + #expect(found?.id == "lbl1") + + let notFound = try store.labelByName("personal", accountId: "acc1") + #expect(notFound == nil) + } + + @Test("projectLabels returns only project labels") + func projectLabels() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertLabel(LabelRecord( + id: "lbl1", accountId: "acc1", name: "context", isProject: false + )) + try store.insertLabel(LabelRecord( + id: "lbl2", accountId: "acc1", name: "my-project", isProject: true + )) + + let projects = try store.projectLabels(accountId: "acc1") + #expect(projects.count == 1) + #expect(projects[0].name == "my-project") + } + + @Test("deleteLabel removes label and cascades to itemLabel") + func deleteLabelCascade() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertLabel(LabelRecord( + id: "lbl1", accountId: "acc1", name: "test" + )) + try store.attachLabel(labelId: "lbl1", itemType: "email", itemId: "m1") + try store.deleteLabel(id: "lbl1") + + let labels = try store.labels(accountId: "acc1") + #expect(labels.isEmpty) + + let items = try store.itemsWithLabel(labelId: "lbl1") + #expect(items.isEmpty) + } + + // MARK: - ItemLabelRecord round-trip + + @Test("ItemLabelRecord attach and fetch round-trip") + func itemLabelRoundTrip() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertLabel(LabelRecord( + id: "lbl1", accountId: "acc1", name: "important" + )) + try store.attachLabel(labelId: "lbl1", itemType: "email", itemId: "m1") + + let labels = try store.labelsForItem(itemType: "email", itemId: "m1") + #expect(labels.count == 1) + #expect(labels[0].id == "lbl1") + + let items = try store.itemsWithLabel(labelId: "lbl1") + #expect(items.count == 1) + #expect(items[0].itemId == "m1") + } + + @Test("detachLabel removes item-label link") + func detachLabel() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertLabel(LabelRecord( + id: "lbl1", accountId: "acc1", name: "test" + )) + try store.attachLabel(labelId: "lbl1", itemType: "email", itemId: "m1") + try store.detachLabel(labelId: "lbl1", itemType: "email", itemId: "m1") + + let labels = try store.labelsForItem(itemType: "email", itemId: "m1") + #expect(labels.isEmpty) + } + + // MARK: - DeferralRecord round-trip + + @Test("DeferralRecord insert and fetch round-trip") + func deferralRoundTrip() throws { + let store = try makeStore() + try makeAccount(store) + try makeMailboxAndMessage(store) + + let deferral = DeferralRecord( + id: "def1", messageId: "m1", + deferUntil: "2026-03-15T09:00:00Z", + originalMailbox: "mb1", + createdAt: "2026-03-14T10:00:00Z" + ) + try store.insertDeferral(deferral) + + let fetched = try store.deferralForMessage(messageId: "m1") + #expect(fetched != nil) + #expect(fetched?.deferUntil == "2026-03-15T09:00:00Z") + #expect(fetched?.originalMailbox == "mb1") + } + + @Test("deferral cascade on message delete") + func deferralCascadeOnMessageDelete() throws { + let store = try makeStore() + try makeAccount(store) + try makeMailboxAndMessage(store) + + try store.insertDeferral(DeferralRecord( + id: "def1", messageId: "m1", + deferUntil: "2026-03-15T09:00:00Z", + createdAt: "2026-03-14T10:00:00Z" + )) + + try store.deleteMessage(id: "m1") + + let fetched = try store.deferralForMessage(messageId: "m1") + #expect(fetched == nil) + } + + @Test("expiredDeferrals returns deferrals before given date") + func expiredDeferrals() throws { + let store = try makeStore() + try makeAccount(store) + try makeMailboxAndMessage(store) + + // Add second message for second deferral + try store.insertMessages([ + MessageRecord( + id: "m2", accountId: "acc1", mailboxId: "mb1", uid: 2, + messageId: "msg2@example.com", inReplyTo: nil, refs: nil, + subject: "Future", fromAddress: nil, fromName: nil, + toAddresses: nil, ccAddresses: nil, + date: "2026-03-14T10:00:00Z", snippet: nil, + bodyText: nil, bodyHtml: nil, + isRead: false, isFlagged: false, size: 0 + ), + ]) + + try store.insertDeferral(DeferralRecord( + id: "def1", messageId: "m1", + deferUntil: "2026-03-14T08:00:00Z", + createdAt: "2026-03-13T10:00:00Z" + )) + try store.insertDeferral(DeferralRecord( + id: "def2", messageId: "m2", + deferUntil: "2026-03-16T08:00:00Z", + createdAt: "2026-03-13T10:00:00Z" + )) + + let expired = try store.expiredDeferrals(beforeDate: "2026-03-15T00:00:00Z") + #expect(expired.count == 1) + #expect(expired[0].id == "def1") + } + + @Test("somedayDeferrals returns deferrals with nil deferUntil") + func somedayDeferrals() throws { + let store = try makeStore() + try makeAccount(store) + try makeMailboxAndMessage(store) + + try store.insertDeferral(DeferralRecord( + id: "def1", messageId: "m1", + deferUntil: nil, + createdAt: "2026-03-14T10:00:00Z" + )) + + let someday = try store.somedayDeferrals() + #expect(someday.count == 1) + #expect(someday[0].id == "def1") + } + + // MARK: - TaskRecord round-trip + + @Test("TaskRecord insert and fetch round-trip") + func taskRoundTrip() throws { + let store = try makeStore() + try makeAccount(store) + + let task = TaskRecord( + id: "t1", accountId: "acc1", summary: "Buy groceries", + description: "Milk, eggs, bread", + status: "NEEDS-ACTION", priority: 5, + dueDate: "2026-03-15", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z", + linkedMessageId: "msg1@example.com" + ) + try store.insertTask(task) + + let fetched = try store.task(id: "t1") + #expect(fetched != nil) + #expect(fetched?.summary == "Buy groceries") + #expect(fetched?.description == "Milk, eggs, bread") + #expect(fetched?.priority == 5) + #expect(fetched?.linkedMessageId == "msg1@example.com") + } + + @Test("inboxTasks returns only active non-deferred non-someday tasks") + func inboxTasks() throws { + let store = try makeStore() + try makeAccount(store) + + // Inbox task + try store.insertTask(TaskRecord( + id: "t1", accountId: "acc1", summary: "Inbox task", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z" + )) + // Deferred task + try store.insertTask(TaskRecord( + id: "t2", accountId: "acc1", summary: "Deferred", + deferUntil: "2026-03-20T10:00:00Z", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z" + )) + // Someday task + try store.insertTask(TaskRecord( + id: "t3", accountId: "acc1", summary: "Someday", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z", + isSomeday: true + )) + // Completed task + try store.insertTask(TaskRecord( + id: "t4", accountId: "acc1", summary: "Done", + status: "COMPLETED", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z" + )) + + let inbox = try store.inboxTasks(accountId: "acc1") + #expect(inbox.count == 1) + #expect(inbox[0].id == "t1") + } + + @Test("deleteAllTasks removes all tasks") + func deleteAllTasks() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertTask(TaskRecord( + id: "t1", accountId: "acc1", summary: "Task 1", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z" + )) + try store.insertTask(TaskRecord( + id: "t2", accountId: "acc1", summary: "Task 2", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z" + )) + + try store.deleteAllTasks() + let remaining = try store.task(id: "t1") + #expect(remaining == nil) + } +} diff --git a/Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift b/Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift new file mode 100644 index 0000000..56bfe85 --- /dev/null +++ b/Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift @@ -0,0 +1,13 @@ +import Testing +@testable import TaskStore +@testable import MailStore + +@Suite("TaskStore") +struct TaskStoreTests { + @Test("TaskStore wraps MailStore") + func taskStoreInit() throws { + let mailStore = try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase()) + let taskStore = TaskStore(mailStore: mailStore) + #expect(taskStore.mailStore === mailStore) + } +}