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) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 08:51:15 +01:00
parent 18e7ff2c47
commit a4f0761f25
14 changed files with 925 additions and 1 deletions

View File

@@ -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
}

View File

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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
public enum TaskStatus: String, Sendable, Codable {
case needsAction = "NEEDS-ACTION"
case inProcess = "IN-PROCESS"
case completed = "COMPLETED"
case cancelled = "CANCELLED"
}

View File

@@ -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
}
}

View File

@@ -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
}
}