import { randomUUID } from "node:crypto"; import pg from "pg"; import type { ExecutionReport, Milestone, TaskGroup, TaskGroupOverview, TaskItem, TaskStatus, Timeline } from "./contracts.ts"; import type { MdtodoImportPlan } from "./mdtodo-import.ts"; const { Pool } = pg; const schema = [ `CREATE TABLE IF NOT EXISTS tasktree_schema_migrations (migration_id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`, `CREATE TABLE IF NOT EXISTS tasktree_groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`, `CREATE TABLE IF NOT EXISTS tasktree_tasks ( id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE, parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask','subsubtask')), title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','in_progress','completed','blocked')), start_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (due_at IS NULL OR start_at IS NULL OR due_at >= start_at))`, `CREATE TABLE IF NOT EXISTS tasktree_milestones ( id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE, task_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, title TEXT NOT NULL, occurs_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`, `CREATE TABLE IF NOT EXISTS tasktree_execution_reports ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasktree_tasks(id) ON DELETE CASCADE, title TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'succeeded', created_at TIMESTAMPTZ NOT NULL DEFAULT now())`, `CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_group_order ON tasktree_tasks(group_id, sort_order, created_at)`, `CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_parent ON tasktree_tasks(parent_id)`, `CREATE INDEX IF NOT EXISTS idx_tasktree_reports_task ON tasktree_execution_reports(task_id, created_at DESC)` ,`ALTER TABLE tasktree_tasks DROP CONSTRAINT IF EXISTS tasktree_tasks_kind_check` ,`ALTER TABLE tasktree_tasks ADD CONSTRAINT tasktree_tasks_kind_check CHECK (kind IN ('task','subtask','subsubtask'))` ]; export class TaskTreeStore { private readonly pool: pg.Pool; private ready = false; constructor(databaseUrl: string) { if (!databaseUrl) throw new Error("TASKTREE_DATABASE_URL or DATABASE_URL is required"); this.pool = new Pool({ connectionString: databaseUrl, max: 4, connectionTimeoutMillis: 5000, ssl: { rejectUnauthorized: false } }); } async ensureSchema() { if (this.ready) return; const client = await this.pool.connect(); try { await client.query("BEGIN"); for (const statement of schema) await client.query(statement); await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1),($2) ON CONFLICT DO NOTHING", ["tasktree-20260716-v1", "tasktree-20260717-v2-subsubtask"]); await client.query("COMMIT"); this.ready = true; } catch (error) { await client.query("ROLLBACK").catch(() => {}); throw error; } finally { client.release(); } } async health() { await this.ensureSchema(); const result = await this.pool.query("SELECT current_database() AS database, now() AS now"); return { storage: "native-postgresql", schemaReady: true, database: result.rows[0]?.database, checkedAt: result.rows[0]?.now }; } async listGroups(): Promise { await this.ensureSchema(); const result = await this.pool.query("SELECT * FROM tasktree_groups ORDER BY updated_at DESC, name"); return result.rows.map(groupRow); } async groupOverview(): Promise { await this.ensureSchema(); const result = await this.pool.query(` SELECT g.*, COUNT(DISTINCT t.id) FILTER (WHERE t.kind='task')::int AS task_count, COUNT(DISTINCT t.id) FILTER (WHERE t.kind='subtask')::int AS subtask_count, COUNT(DISTINCT t.id) FILTER (WHERE t.kind='subsubtask')::int AS subsubtask_count, COUNT(DISTINCT r.id)::int AS report_count, MIN(t.start_at) AS start_at, MAX(t.due_at) AS due_at FROM tasktree_groups g LEFT JOIN tasktree_tasks t ON t.group_id=g.id LEFT JOIN tasktree_execution_reports r ON r.task_id=t.id GROUP BY g.id ORDER BY g.updated_at DESC, g.name `); return result.rows.map(overviewRow); } async getGroup(id: string): Promise { await this.ensureSchema(); const result = await this.pool.query("SELECT * FROM tasktree_groups WHERE id=$1", [id]); return result.rows[0] ? groupRow(result.rows[0]) : null; } async createGroup(name: string, description = ""): Promise { await this.ensureSchema(); const result = await this.pool.query("INSERT INTO tasktree_groups (id,name,description) VALUES ($1,$2,$3) RETURNING *", [`tg_${randomUUID()}`, name, description]); return groupRow(result.rows[0]); } async deleteGroup(id: string): Promise { await this.ensureSchema(); return (await this.pool.query("DELETE FROM tasktree_groups WHERE id=$1", [id])).rowCount === 1; } async createTask(input: { groupId: string; title: string; description?: string; startAt?: string; dueAt?: string; parentId?: string }): Promise { await this.ensureSchema(); let kind = "task"; if (input.parentId) { const parent = await this.pool.query("SELECT group_id,kind FROM tasktree_tasks WHERE id=$1", [input.parentId]); if (!parent.rows[0]) throw domainError("parent_not_found", "parent task was not found"); if (parent.rows[0].group_id !== input.groupId) throw domainError("parent_group_mismatch", "parent task belongs to another taskgroup"); if (parent.rows[0].kind === "subsubtask") throw domainError("maximum_depth_exceeded", "TaskTree supports task, subtask, and subsubtask only"); kind = parent.rows[0].kind === "task" ? "subtask" : "subsubtask"; } const result = await this.pool.query( `INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,start_at,due_at,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,(SELECT COALESCE(MAX(sort_order),-1)+1 FROM tasktree_tasks WHERE group_id=$2)) RETURNING *`, [`tt_${randomUUID()}`, input.groupId, input.parentId ?? null, kind, input.title, input.description ?? "", input.startAt ?? null, input.dueAt ?? null] ); return taskRow(result.rows[0]); } async updateTask(id: string, patch: { title?: string; description?: string; status?: TaskStatus; startAt?: string | null; dueAt?: string | null }): Promise { await this.ensureSchema(); const current = await this.pool.query("SELECT * FROM tasktree_tasks WHERE id=$1", [id]); if (!current.rows[0]) return null; const row = taskRow(current.rows[0]); const result = await this.pool.query( `UPDATE tasktree_tasks SET title=$2,description=$3,status=$4,start_at=$5,due_at=$6,updated_at=now() WHERE id=$1 RETURNING *`, [id, patch.title ?? row.title, patch.description ?? row.description, patch.status ?? row.status, patch.startAt === undefined ? row.startAt : patch.startAt, patch.dueAt === undefined ? row.dueAt : patch.dueAt] ); return taskRow(result.rows[0]); } async deleteTask(id: string): Promise { await this.ensureSchema(); return (await this.pool.query("DELETE FROM tasktree_tasks WHERE id=$1", [id])).rowCount === 1; } async createMilestone(input: { groupId: string; title: string; occursAt: string; taskId?: string }): Promise { await this.ensureSchema(); const result = await this.pool.query( "INSERT INTO tasktree_milestones (id,group_id,task_id,title,occurs_at) VALUES ($1,$2,$3,$4,$5) RETURNING *", [`tm_${randomUUID()}`, input.groupId, input.taskId ?? null, input.title, input.occursAt] ); return milestoneRow(result.rows[0]); } async createReport(input: { taskId: string; title: string; body: string; status?: string }): Promise { await this.ensureSchema(); const result = await this.pool.query( "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5) RETURNING *", [`tr_${randomUUID()}`, input.taskId, input.title, input.body, input.status ?? "succeeded"] ); return reportRow(result.rows[0]); } async importMdtodo(plan: MdtodoImportPlan) { await this.ensureSchema(); const client = await this.pool.connect(); const groupId = `tg_${randomUUID()}`; const taskIds = new Map(); try { await client.query("BEGIN"); const groupResult = await client.query( "INSERT INTO tasktree_groups (id,name,description) VALUES ($1,$2,$3) RETURNING *", [groupId, plan.groupName, plan.groupDescription] ); for (let index = 0; index < plan.tasks.length; index += 1) { const task = plan.tasks[index]; const taskId = `tt_${randomUUID()}`; taskIds.set(task.sourceId, taskId); const parentId = task.parentSourceId ? taskIds.get(task.parentSourceId) ?? null : null; const parentTask = parentId ? plan.tasks.find((candidate) => candidate.sourceId === task.parentSourceId) : null; const kind = !parentId ? "task" : parentTask?.parentSourceId ? "subsubtask" : "subtask"; await client.query( `INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,start_at,due_at,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, [taskId, groupId, parentId, kind, task.title, task.description, task.status, task.startAt, task.dueAt, index] ); for (const report of task.reports) { await client.query( "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status,created_at) VALUES ($1,$2,$3,$4,$5,$6)", [`tr_${randomUUID()}`, taskId, report.title, report.body, report.status, report.createdAt] ); } } await client.query("COMMIT"); return { dryRun: false, sourcePath: plan.sourcePath, group: groupRow(groupResult.rows[0]), parserMode: plan.parserMode, summary: plan.summary, warnings: plan.warnings }; } catch (error) { await client.query("ROLLBACK").catch(() => {}); throw error; } finally { client.release(); } } async timeline(groupId: string): Promise { await this.ensureSchema(); const group = await this.getGroup(groupId); if (!group) return null; const [tasks, milestones, reports] = await Promise.all([ this.pool.query("SELECT * FROM tasktree_tasks WHERE group_id=$1 ORDER BY sort_order,created_at", [groupId]), this.pool.query("SELECT * FROM tasktree_milestones WHERE group_id=$1 ORDER BY occurs_at", [groupId]), this.pool.query("SELECT r.* FROM tasktree_execution_reports r JOIN tasktree_tasks t ON t.id=r.task_id WHERE t.group_id=$1 ORDER BY r.created_at DESC", [groupId]) ]); return { group, tasks: tasks.rows.map(taskRow), milestones: milestones.rows.map(milestoneRow), reports: reports.rows.map(reportRow) }; } async close() { await this.pool.end(); } } function groupRow(row: any): TaskGroup { return { id: row.id, name: row.name, description: row.description, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; } function overviewRow(row: any): TaskGroupOverview { return { group: groupRow(row), taskCount: Number(row.task_count), subtaskCount: Number(row.subtask_count), subsubtaskCount: Number(row.subsubtask_count), reportCount: Number(row.report_count), startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at) }; } function taskRow(row: any): TaskItem { return { id: row.id, groupId: row.group_id, parentId: row.parent_id, kind: row.kind, title: row.title, description: row.description, status: row.status, startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at), sortOrder: row.sort_order, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; } function milestoneRow(row: any): Milestone { return { id: row.id, groupId: row.group_id, taskId: row.task_id, title: row.title, occursAt: iso(row.occurs_at), createdAt: iso(row.created_at) }; } function reportRow(row: any): ExecutionReport { return { id: row.id, taskId: row.task_id, title: row.title, body: row.body, status: row.status, createdAt: iso(row.created_at) }; } function iso(value: unknown): string { return new Date(value as any).toISOString(); } function nullableIso(value: unknown): string | null { return value == null ? null : iso(value); } function domainError(code: string, message: string) { return Object.assign(new Error(message), { code }); }