feat: add api-storage improvements (#1278)

This commit is contained in:
Ralph Khreish
2025-10-06 15:23:48 +02:00
committed by GitHub
parent 7b5a7c4495
commit db6f405f23
14 changed files with 501 additions and 139 deletions

View File

@@ -246,7 +246,7 @@ export class ListTasksCommand extends Command {
task.subtasks.forEach((subtask) => { task.subtasks.forEach((subtask) => {
const subIcon = STATUS_ICONS[subtask.status]; const subIcon = STATUS_ICONS[subtask.status];
console.log( console.log(
` ${chalk.gray(`${task.id}.${subtask.id}`)} ${subIcon} ${chalk.gray(subtask.title)}` ` ${chalk.gray(String(subtask.id))} ${subIcon} ${chalk.gray(subtask.title)}`
); );
}); });
} }
@@ -297,7 +297,7 @@ export class ListTasksCommand extends Command {
nextTask nextTask
); );
// Task table - no title, just show the table directly // Task table
console.log( console.log(
ui.createTaskTable(tasks, { ui.createTaskTable(tasks, {
showSubtasks: withSubtasks, showSubtasks: withSubtasks,

View File

@@ -258,9 +258,6 @@ export class SetStatusCommand extends Command {
) )
); );
} }
// Show storage info
console.log(chalk.gray(`\nUsing ${result.storageType} storage`));
} }
/** /**

View File

@@ -192,8 +192,7 @@ export function displaySubtasks(
status: any; status: any;
description?: string; description?: string;
dependencies?: string[]; dependencies?: string[];
}>, }>
parentId: string | number
): void { ): void {
const terminalWidth = process.stdout.columns * 0.95 || 100; const terminalWidth = process.stdout.columns * 0.95 || 100;
// Display subtasks header // Display subtasks header
@@ -228,7 +227,7 @@ export function displaySubtasks(
}); });
subtasks.forEach((subtask) => { subtasks.forEach((subtask) => {
const subtaskId = `${parentId}.${subtask.id}`; const subtaskId = String(subtask.id);
// Format dependencies // Format dependencies
const deps = const deps =
@@ -329,7 +328,7 @@ export function displayTaskDetails(
console.log(chalk.gray(` No subtasks with status '${statusFilter}'`)); console.log(chalk.gray(` No subtasks with status '${statusFilter}'`));
} else if (filteredSubtasks.length > 0) { } else if (filteredSubtasks.length > 0) {
console.log(); // Empty line for spacing console.log(); // Empty line for spacing
displaySubtasks(filteredSubtasks, task.id); displaySubtasks(filteredSubtasks);
} }
} }

View File

@@ -286,12 +286,12 @@ export function createTaskTable(
// Adjust column widths to better match the original layout // Adjust column widths to better match the original layout
const baseColWidths = showComplexity const baseColWidths = showComplexity
? [ ? [
Math.floor(terminalWidth * 0.06), Math.floor(terminalWidth * 0.1),
Math.floor(terminalWidth * 0.4), Math.floor(terminalWidth * 0.4),
Math.floor(terminalWidth * 0.15), Math.floor(terminalWidth * 0.15),
Math.floor(terminalWidth * 0.12), Math.floor(terminalWidth * 0.1),
Math.floor(terminalWidth * 0.2), Math.floor(terminalWidth * 0.2),
Math.floor(terminalWidth * 0.12) Math.floor(terminalWidth * 0.1)
] // ID, Title, Status, Priority, Dependencies, Complexity ] // ID, Title, Status, Priority, Dependencies, Complexity
: [ : [
Math.floor(terminalWidth * 0.08), Math.floor(terminalWidth * 0.08),
@@ -377,7 +377,11 @@ export function createTaskTable(
} }
if (showComplexity) { if (showComplexity) {
subRow.push(chalk.gray('--')); const complexityDisplay =
typeof subtask.complexity === 'number'
? getComplexityWithColor(subtask.complexity)
: '--';
subRow.push(chalk.gray(complexityDisplay));
} }
table.push(subRow); table.push(subRow);

41
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "task-master-ai", "name": "task-master-ai",
"version": "0.27.3", "version": "0.28.0-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "task-master-ai", "name": "task-master-ai",
"version": "0.27.3", "version": "0.28.0-rc.1",
"license": "MIT WITH Commons-Clause", "license": "MIT WITH Commons-Clause",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -131,7 +131,7 @@
} }
}, },
"apps/extension": { "apps/extension": {
"version": "0.25.4", "version": "0.25.5-rc.0",
"dependencies": { "dependencies": {
"task-master-ai": "*" "task-master-ai": "*"
}, },
@@ -635,7 +635,6 @@
"apps/extension/node_modules/zod": { "apps/extension/node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -1830,7 +1829,6 @@
"version": "7.28.4", "version": "7.28.4",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@@ -2663,7 +2661,6 @@
"version": "6.3.1", "version": "6.3.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -4583,6 +4580,7 @@
"version": "0.23.2", "version": "0.23.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@@ -5172,6 +5170,7 @@
"version": "0.23.2", "version": "0.23.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@@ -5180,7 +5179,6 @@
"version": "3.25.76", "version": "3.25.76",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -5471,7 +5469,6 @@
"node_modules/@modelcontextprotocol/sdk/node_modules/zod": { "node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -5572,7 +5569,6 @@
"node_modules/@opentelemetry/api": { "node_modules/@opentelemetry/api": {
"version": "1.9.0", "version": "1.9.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -8592,7 +8588,6 @@
"version": "19.1.8", "version": "19.1.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -8601,7 +8596,6 @@
"version": "19.1.6", "version": "19.1.6",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
@@ -9047,7 +9041,6 @@
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -9113,7 +9106,6 @@
"node_modules/ai": { "node_modules/ai": {
"version": "5.0.57", "version": "5.0.57",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@ai-sdk/gateway": "1.0.30", "@ai-sdk/gateway": "1.0.30",
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
@@ -9333,7 +9325,6 @@
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.17.1", "version": "8.17.1",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -10339,7 +10330,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@@ -12203,8 +12193,7 @@
"node_modules/devtools-protocol": { "node_modules/devtools-protocol": {
"version": "0.0.1312386", "version": "0.0.1312386",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause"
"peer": true
}, },
"node_modules/dezalgo": { "node_modules/dezalgo": {
"version": "1.0.4", "version": "1.0.4",
@@ -12798,7 +12787,6 @@
"version": "0.25.10", "version": "0.25.10",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -13111,7 +13099,6 @@
"node_modules/express": { "node_modules/express": {
"version": "4.21.2", "version": "4.21.2",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -15465,7 +15452,6 @@
"version": "6.3.1", "version": "6.3.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.0", "@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0", "ansi-escapes": "^7.0.0",
@@ -16423,7 +16409,6 @@
"version": "29.7.0", "version": "29.7.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@@ -18041,7 +18026,6 @@
"version": "1.4.0", "version": "1.4.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 10.16.0" "node": ">= 10.16.0"
} }
@@ -18367,6 +18351,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -18591,6 +18576,7 @@
"version": "1.4.0", "version": "1.4.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
}, },
@@ -18721,7 +18707,6 @@
"node_modules/marked": { "node_modules/marked": {
"version": "15.0.12", "version": "15.0.12",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
@@ -21444,7 +21429,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -22827,7 +22811,6 @@
"integrity": "sha512-U+NPR0Bkg3wm61dteD2L4nAM1U9dtaqVrpDXwC36IKRHpEO/Ubpid4Nijpa2imPchcVNHfxVFwSSMJdwdGFUbg==", "integrity": "sha512-U+NPR0Bkg3wm61dteD2L4nAM1U9dtaqVrpDXwC36IKRHpEO/Ubpid4Nijpa2imPchcVNHfxVFwSSMJdwdGFUbg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.93.0", "@oxc-project/types": "=0.93.0",
"@rolldown/pluginutils": "1.0.0-beta.41", "@rolldown/pluginutils": "1.0.0-beta.41",
@@ -25256,7 +25239,6 @@
"version": "5.9.2", "version": "5.9.2",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -25373,7 +25355,6 @@
"version": "11.0.5", "version": "11.0.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/unist": "^3.0.0", "@types/unist": "^3.0.0",
"bail": "^2.0.0", "bail": "^2.0.0",
@@ -25816,7 +25797,6 @@
"version": "5.4.20", "version": "5.4.20",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -25929,6 +25909,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -26512,7 +26493,7 @@
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "1.10.2", "version": "1.10.2",
"dev": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -26655,7 +26636,6 @@
"node_modules/zod": { "node_modules/zod": {
"version": "4.1.11", "version": "4.1.11",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -27397,7 +27377,6 @@
"version": "3.2.4", "version": "3.2.4",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",

View File

@@ -53,7 +53,7 @@ export class TaskEntity implements Task {
// Normalize subtask IDs to strings // Normalize subtask IDs to strings
this.subtasks = (data.subtasks || []).map((subtask) => ({ this.subtasks = (data.subtasks || []).map((subtask) => ({
...subtask, ...subtask,
id: Number(subtask.id), // Keep subtask IDs as numbers per interface id: String(subtask.id),
parentId: String(subtask.parentId) parentId: String(subtask.parentId)
})); }));

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, vi } from 'vitest';
import { TaskMapper } from './TaskMapper.js';
import type { Tables } from '../types/database.types.js';
type TaskRow = Tables<'tasks'>;
describe('TaskMapper', () => {
describe('extractMetadataField', () => {
it('should extract string field from metadata', () => {
const taskRow: TaskRow = {
id: '123',
display_id: '1',
title: 'Test Task',
description: 'Test description',
status: 'todo',
priority: 'medium',
parent_task_id: null,
subtask_position: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: {
details: 'Some details',
testStrategy: 'Test with unit tests'
},
complexity: null,
assignee_id: null,
estimated_hours: null,
actual_hours: null,
due_date: null,
completed_at: null
};
const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map());
expect(task.details).toBe('Some details');
expect(task.testStrategy).toBe('Test with unit tests');
});
it('should use default value when metadata field is missing', () => {
const taskRow: TaskRow = {
id: '123',
display_id: '1',
title: 'Test Task',
description: 'Test description',
status: 'todo',
priority: 'medium',
parent_task_id: null,
subtask_position: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: {},
complexity: null,
assignee_id: null,
estimated_hours: null,
actual_hours: null,
due_date: null,
completed_at: null
};
const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map());
expect(task.details).toBe('');
expect(task.testStrategy).toBe('');
});
it('should use default value when metadata is null', () => {
const taskRow: TaskRow = {
id: '123',
display_id: '1',
title: 'Test Task',
description: 'Test description',
status: 'todo',
priority: 'medium',
parent_task_id: null,
subtask_position: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: null,
complexity: null,
assignee_id: null,
estimated_hours: null,
actual_hours: null,
due_date: null,
completed_at: null
};
const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map());
expect(task.details).toBe('');
expect(task.testStrategy).toBe('');
});
it('should use default value and warn when metadata field has wrong type', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {});
const taskRow: TaskRow = {
id: '123',
display_id: '1',
title: 'Test Task',
description: 'Test description',
status: 'todo',
priority: 'medium',
parent_task_id: null,
subtask_position: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: {
details: 12345, // Wrong type: number instead of string
testStrategy: ['test1', 'test2'] // Wrong type: array instead of string
},
complexity: null,
assignee_id: null,
estimated_hours: null,
actual_hours: null,
due_date: null,
completed_at: null
};
const task = TaskMapper.mapDatabaseTaskToTask(taskRow, [], new Map());
// Should use empty string defaults when type doesn't match
expect(task.details).toBe('');
expect(task.testStrategy).toBe('');
// Should have logged warnings
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Type mismatch in metadata field "details"')
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Type mismatch in metadata field "testStrategy"'
)
);
consoleWarnSpy.mockRestore();
});
});
describe('mapStatus', () => {
it('should map database status to internal status', () => {
expect(TaskMapper.mapStatus('todo')).toBe('pending');
expect(TaskMapper.mapStatus('in_progress')).toBe('in-progress');
expect(TaskMapper.mapStatus('done')).toBe('done');
});
});
});

View File

@@ -2,22 +2,32 @@ import { Task, Subtask } from '../types/index.js';
import { Database, Tables } from '../types/database.types.js'; import { Database, Tables } from '../types/database.types.js';
type TaskRow = Tables<'tasks'>; type TaskRow = Tables<'tasks'>;
type DependencyRow = Tables<'task_dependencies'>;
// Legacy type for backward compatibility
type DependencyRow = Tables<'task_dependencies'> & {
depends_on_task?: { display_id: string } | null;
depends_on_task_id?: string;
};
export class TaskMapper { export class TaskMapper {
/** /**
* Maps database tasks to internal Task format * Maps database tasks to internal Task format
* @param dbTasks - Array of tasks from database
* @param dependencies - Either a Map of task_id to display_ids or legacy array format
*/ */
static mapDatabaseTasksToTasks( static mapDatabaseTasksToTasks(
dbTasks: TaskRow[], dbTasks: TaskRow[],
dbDependencies: DependencyRow[] dependencies: Map<string, string[]> | DependencyRow[]
): Task[] { ): Task[] {
if (!dbTasks || dbTasks.length === 0) { if (!dbTasks || dbTasks.length === 0) {
return []; return [];
} }
// Group dependencies by task_id // Handle both Map and array formats for backward compatibility
const dependenciesByTaskId = this.groupDependenciesByTaskId(dbDependencies); const dependenciesByTaskId =
dependencies instanceof Map
? dependencies
: this.groupDependenciesByTaskId(dependencies);
// Separate parent tasks and subtasks // Separate parent tasks and subtasks
const parentTasks = dbTasks.filter((t) => !t.parent_task_id); const parentTasks = dbTasks.filter((t) => !t.parent_task_id);
@@ -43,21 +53,23 @@ export class TaskMapper {
): Task { ): Task {
// Map subtasks // Map subtasks
const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => ({ const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => ({
id: index + 1, // Use numeric ID for subtasks id: subtask.display_id || String(index + 1), // Use display_id if available (API storage), fallback to numeric (file storage)
parentId: dbTask.id, parentId: dbTask.id,
title: subtask.title, title: subtask.title,
description: subtask.description || '', description: subtask.description || '',
status: this.mapStatus(subtask.status), status: this.mapStatus(subtask.status),
priority: this.mapPriority(subtask.priority), priority: this.mapPriority(subtask.priority),
dependencies: dependenciesByTaskId.get(subtask.id) || [], dependencies: dependenciesByTaskId.get(subtask.id) || [],
details: (subtask.metadata as any)?.details || '', details: this.extractMetadataField(subtask.metadata, 'details', ''),
testStrategy: (subtask.metadata as any)?.testStrategy || '', testStrategy: this.extractMetadataField(
subtask.metadata,
'testStrategy',
''
),
createdAt: subtask.created_at, createdAt: subtask.created_at,
updatedAt: subtask.updated_at, updatedAt: subtask.updated_at,
assignee: subtask.assignee_id || undefined, assignee: subtask.assignee_id || undefined,
complexity: subtask.complexity complexity: subtask.complexity ?? undefined
? this.mapComplexityToInternal(subtask.complexity)
: undefined
})); }));
return { return {
@@ -67,22 +79,25 @@ export class TaskMapper {
status: this.mapStatus(dbTask.status), status: this.mapStatus(dbTask.status),
priority: this.mapPriority(dbTask.priority), priority: this.mapPriority(dbTask.priority),
dependencies: dependenciesByTaskId.get(dbTask.id) || [], dependencies: dependenciesByTaskId.get(dbTask.id) || [],
details: (dbTask.metadata as any)?.details || '', details: this.extractMetadataField(dbTask.metadata, 'details', ''),
testStrategy: (dbTask.metadata as any)?.testStrategy || '', testStrategy: this.extractMetadataField(
dbTask.metadata,
'testStrategy',
''
),
subtasks, subtasks,
createdAt: dbTask.created_at, createdAt: dbTask.created_at,
updatedAt: dbTask.updated_at, updatedAt: dbTask.updated_at,
assignee: dbTask.assignee_id || undefined, assignee: dbTask.assignee_id || undefined,
complexity: dbTask.complexity complexity: dbTask.complexity ?? undefined,
? this.mapComplexityToInternal(dbTask.complexity)
: undefined,
effort: dbTask.estimated_hours || undefined, effort: dbTask.estimated_hours || undefined,
actualEffort: dbTask.actual_hours || undefined actualEffort: dbTask.actual_hours || undefined
}; };
} }
/** /**
* Groups dependencies by task ID * Groups dependencies by task ID (legacy method for backward compatibility)
* @deprecated Use DependencyFetcher.fetchDependenciesWithDisplayIds instead
*/ */
private static groupDependenciesByTaskId( private static groupDependenciesByTaskId(
dependencies: DependencyRow[] dependencies: DependencyRow[]
@@ -92,7 +107,14 @@ export class TaskMapper {
if (dependencies) { if (dependencies) {
for (const dep of dependencies) { for (const dep of dependencies) {
const deps = dependenciesByTaskId.get(dep.task_id) || []; const deps = dependenciesByTaskId.get(dep.task_id) || [];
deps.push(dep.depends_on_task_id); // Handle both old format (UUID string) and new format (object with display_id)
const dependencyId =
typeof dep.depends_on_task === 'object'
? dep.depends_on_task?.display_id
: dep.depends_on_task_id;
if (dependencyId) {
deps.push(dependencyId);
}
dependenciesByTaskId.set(dep.task_id, deps); dependenciesByTaskId.set(dep.task_id, deps);
} }
} }
@@ -157,14 +179,38 @@ export class TaskMapper {
} }
/** /**
* Maps numeric complexity to descriptive complexity * Safely extracts a field from metadata JSON with runtime type validation
* @param metadata The metadata object (could be null or any type)
* @param field The field to extract
* @param defaultValue Default value if field doesn't exist
* @returns The extracted value if it matches the expected type, otherwise defaultValue
*/ */
private static mapComplexityToInternal( private static extractMetadataField<T>(
complexity: number metadata: unknown,
): Task['complexity'] { field: string,
if (complexity <= 2) return 'simple'; defaultValue: T
if (complexity <= 5) return 'moderate'; ): T {
if (complexity <= 8) return 'complex'; if (!metadata || typeof metadata !== 'object') {
return 'very-complex'; return defaultValue;
}
const value = (metadata as Record<string, unknown>)[field];
if (value === undefined) {
return defaultValue;
}
// Runtime type validation: ensure value matches the type of defaultValue
const expectedType = typeof defaultValue;
const actualType = typeof value;
if (expectedType !== actualType) {
console.warn(
`Type mismatch in metadata field "${field}": expected ${expectedType}, got ${actualType}. Using default value.`
);
return defaultValue;
}
return value as T;
} }
} }

View File

@@ -0,0 +1,68 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '../../types/database.types.js';
import { DependencyWithDisplayId } from '../../types/repository-types.js';
/**
* Handles fetching and processing of task dependencies with display_ids
*/
export class DependencyFetcher {
constructor(private supabase: SupabaseClient<Database>) {}
/**
* Fetches dependencies for given task IDs with display_ids joined
* @param taskIds Array of task IDs to fetch dependencies for
* @returns Map of task ID to array of dependency display_ids
*/
async fetchDependenciesWithDisplayIds(
taskIds: string[]
): Promise<Map<string, string[]>> {
if (!taskIds || taskIds.length === 0) {
return new Map();
}
const { data, error } = await this.supabase
.from('task_dependencies')
.select(`
task_id,
depends_on_task:tasks!task_dependencies_depends_on_task_id_fkey (
display_id
)
`)
.in('task_id', taskIds);
if (error) {
throw new Error(`Failed to fetch task dependencies: ${error.message}`);
}
return this.processDependencyData(data as DependencyWithDisplayId[]);
}
/**
* Processes raw dependency data into a map structure
*/
private processDependencyData(
dependencies: DependencyWithDisplayId[]
): Map<string, string[]> {
const dependenciesByTaskId = new Map<string, string[]>();
if (!dependencies) {
return dependenciesByTaskId;
}
for (const dep of dependencies) {
if (!dep.task_id) continue;
const currentDeps = dependenciesByTaskId.get(dep.task_id) || [];
// Extract display_id from the joined object
const displayId = dep.depends_on_task?.display_id;
if (displayId) {
currentDeps.push(displayId);
}
dependenciesByTaskId.set(dep.task_id, currentDeps);
}
return dependenciesByTaskId;
}
}

View File

@@ -0,0 +1,5 @@
/**
* Supabase repository implementations
*/
export { SupabaseTaskRepository } from './supabase-task-repository.js';
export { DependencyFetcher } from './dependency-fetcher.js';

View File

@@ -1,8 +1,13 @@
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { Task } from '../types/index.js'; import { Task } from '../../types/index.js';
import { Database } from '../types/database.types.js'; import { Database, Json } from '../../types/database.types.js';
import { TaskMapper } from '../mappers/TaskMapper.js'; import { TaskMapper } from '../../mappers/TaskMapper.js';
import { AuthManager } from '../auth/auth-manager.js'; import { AuthManager } from '../../auth/auth-manager.js';
import { DependencyFetcher } from './dependency-fetcher.js';
import {
TaskWithRelations,
TaskDatabaseUpdate
} from '../../types/repository-types.js';
import { z } from 'zod'; import { z } from 'zod';
// Zod schema for task status validation // Zod schema for task status validation
@@ -29,18 +34,30 @@ const TaskUpdateSchema = z
.partial(); .partial();
export class SupabaseTaskRepository { export class SupabaseTaskRepository {
constructor(private supabase: SupabaseClient<Database>) {} private dependencyFetcher: DependencyFetcher;
private authManager: AuthManager;
async getTasks(_projectId?: string): Promise<Task[]> { constructor(private supabase: SupabaseClient<Database>) {
// Get the current context to determine briefId this.dependencyFetcher = new DependencyFetcher(supabase);
const authManager = AuthManager.getInstance(); this.authManager = AuthManager.getInstance();
const context = authManager.getContext(); }
if (!context || !context.briefId) { /**
* Gets the current brief ID from auth context
* @throws {Error} If no brief is selected
*/
private getBriefIdOrThrow(): string {
const context = this.authManager.getContext();
if (!context?.briefId) {
throw new Error( throw new Error(
'No brief selected. Please select a brief first using: tm context brief' 'No brief selected. Please select a brief first using: tm context brief'
); );
} }
return context.briefId;
}
async getTasks(_projectId?: string): Promise<Task[]> {
const briefId = this.getBriefIdOrThrow();
// Get all tasks for the brief using the exact query structure // Get all tasks for the brief using the exact query structure
const { data: tasks, error } = await this.supabase const { data: tasks, error } = await this.supabase
@@ -54,7 +71,7 @@ export class SupabaseTaskRepository {
description description
) )
`) `)
.eq('brief_id', context.briefId) .eq('brief_id', briefId)
.order('position', { ascending: true }) .order('position', { ascending: true })
.order('subtask_position', { ascending: true }) .order('subtask_position', { ascending: true })
.order('created_at', { ascending: true }); .order('created_at', { ascending: true });
@@ -67,38 +84,23 @@ export class SupabaseTaskRepository {
return []; return [];
} }
// Get all dependencies for these tasks // Type-safe task ID extraction
const taskIds = tasks.map((t: any) => t.id); const typedTasks = tasks as TaskWithRelations[];
const { data: depsData, error: depsError } = await this.supabase const taskIds = typedTasks.map((t) => t.id);
.from('task_dependencies') const dependenciesMap =
.select('*') await this.dependencyFetcher.fetchDependenciesWithDisplayIds(taskIds);
.in('task_id', taskIds);
if (depsError) {
throw new Error(
`Failed to fetch task dependencies: ${depsError.message}`
);
}
// Use mapper to convert to internal format // Use mapper to convert to internal format
return TaskMapper.mapDatabaseTasksToTasks(tasks, depsData || []); return TaskMapper.mapDatabaseTasksToTasks(tasks, dependenciesMap);
} }
async getTask(_projectId: string, taskId: string): Promise<Task | null> { async getTask(_projectId: string, taskId: string): Promise<Task | null> {
// Get the current context to determine briefId (projectId not used in Supabase context) const briefId = this.getBriefIdOrThrow();
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context || !context.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
);
}
const { data, error } = await this.supabase const { data, error } = await this.supabase
.from('tasks') .from('tasks')
.select('*') .select('*')
.eq('brief_id', context.briefId) .eq('brief_id', briefId)
.eq('display_id', taskId.toUpperCase()) .eq('display_id', taskId.toUpperCase())
.single(); .single();
@@ -109,30 +111,19 @@ export class SupabaseTaskRepository {
throw new Error(`Failed to fetch task: ${error.message}`); throw new Error(`Failed to fetch task: ${error.message}`);
} }
// Get dependencies for this task
const { data: depsData } = await this.supabase
.from('task_dependencies')
.select('*')
.eq('task_id', taskId);
// Get subtasks if this is a parent task // Get subtasks if this is a parent task
const { data: subtasksData } = await this.supabase const { data: subtasksData } = await this.supabase
.from('tasks') .from('tasks')
.select('*') .select('*')
.eq('parent_task_id', taskId) .eq('parent_task_id', data.id)
.order('subtask_position', { ascending: true }); .order('subtask_position', { ascending: true });
// Create dependency map // Get all task IDs (parent + subtasks) to fetch dependencies
const dependenciesByTaskId = new Map<string, string[]>(); const allTaskIds = [data.id, ...(subtasksData?.map((st) => st.id) || [])];
if (depsData) {
dependenciesByTaskId.set( // Fetch dependencies using the dedicated fetcher
taskId, const dependenciesByTaskId =
depsData.map( await this.dependencyFetcher.fetchDependenciesWithDisplayIds(allTaskIds);
(d: Database['public']['Tables']['task_dependencies']['Row']) =>
d.depends_on_task_id
)
);
}
// Use mapper to convert single task // Use mapper to convert single task
return TaskMapper.mapDatabaseTaskToTask( return TaskMapper.mapDatabaseTaskToTask(
@@ -147,15 +138,7 @@ export class SupabaseTaskRepository {
taskId: string, taskId: string,
updates: Partial<Task> updates: Partial<Task>
): Promise<Task> { ): Promise<Task> {
// Get the current context to determine briefId const briefId = this.getBriefIdOrThrow();
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context || !context.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
);
}
// Validate updates using Zod schema // Validate updates using Zod schema
try { try {
@@ -170,22 +153,50 @@ export class SupabaseTaskRepository {
throw error; throw error;
} }
// Convert Task fields to database fields - only include fields that actually exist in the database // Convert Task fields to database fields with proper typing
const dbUpdates: any = {}; const dbUpdates: TaskDatabaseUpdate = {};
if (updates.title !== undefined) dbUpdates.title = updates.title; if (updates.title !== undefined) dbUpdates.title = updates.title;
if (updates.description !== undefined) if (updates.description !== undefined)
dbUpdates.description = updates.description; dbUpdates.description = updates.description;
if (updates.status !== undefined) if (updates.status !== undefined)
dbUpdates.status = this.mapStatusToDatabase(updates.status); dbUpdates.status = this.mapStatusToDatabase(updates.status);
if (updates.priority !== undefined) dbUpdates.priority = updates.priority; if (updates.priority !== undefined)
// Skip fields that don't exist in database schema: details, testStrategy, etc. dbUpdates.priority = this.mapPriorityToDatabase(updates.priority);
// Handle metadata fields (details, testStrategy, etc.)
// Load existing metadata to preserve fields not being updated
const { data: existingMetadataRow, error: existingMetadataError } =
await this.supabase
.from('tasks')
.select('metadata')
.eq('brief_id', briefId)
.eq('display_id', taskId.toUpperCase())
.single();
if (existingMetadataError) {
throw new Error(
`Failed to load existing task metadata: ${existingMetadataError.message}`
);
}
const metadata: Record<string, unknown> = {
...((existingMetadataRow?.metadata as Record<string, unknown>) ?? {})
};
if (updates.details !== undefined) metadata.details = updates.details;
if (updates.testStrategy !== undefined)
metadata.testStrategy = updates.testStrategy;
if (Object.keys(metadata).length > 0) {
dbUpdates.metadata = metadata as Json;
}
// Update the task // Update the task
const { error } = await this.supabase const { error } = await this.supabase
.from('tasks') .from('tasks')
.update(dbUpdates) .update(dbUpdates)
.eq('brief_id', context.briefId) .eq('brief_id', briefId)
.eq('display_id', taskId.toUpperCase()); .eq('display_id', taskId.toUpperCase());
if (error) { if (error) {
@@ -221,4 +232,25 @@ export class SupabaseTaskRepository {
); );
} }
} }
/**
* Maps internal priority to database priority
* Task Master uses 'critical', database uses 'urgent'
*/
private mapPriorityToDatabase(
priority: string
): Database['public']['Enums']['task_priority'] {
switch (priority) {
case 'critical':
return 'urgent';
case 'low':
case 'medium':
case 'high':
return priority as Database['public']['Enums']['task_priority'];
default:
throw new Error(
`Invalid task priority: ${priority}. Valid priorities are: low, medium, high, critical`
);
}
}
} }

View File

@@ -16,7 +16,7 @@ import type {
} from '../types/index.js'; } from '../types/index.js';
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js'; import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
import { TaskRepository } from '../repositories/task-repository.interface.js'; import { TaskRepository } from '../repositories/task-repository.interface.js';
import { SupabaseTaskRepository } from '../repositories/supabase-task-repository.js'; import { SupabaseTaskRepository } from '../repositories/supabase/index.js';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { AuthManager } from '../auth/auth-manager.js'; import { AuthManager } from '../auth/auth-manager.js';

View File

@@ -82,10 +82,11 @@ export interface Task {
} }
/** /**
* Subtask interface extending Task with numeric ID * Subtask interface extending Task
* ID can be number (file storage) or string (API storage with display_id)
*/ */
export interface Subtask extends Omit<Task, 'id' | 'subtasks'> { export interface Subtask extends Omit<Task, 'id' | 'subtasks'> {
id: number; id: number | string;
parentId: string; parentId: string;
subtasks?: never; // Subtasks cannot have their own subtasks subtasks?: never; // Subtasks cannot have their own subtasks
} }

View File

@@ -0,0 +1,83 @@
/**
* Type definitions for repository operations
*/
import { Database, Tables } from './database.types.js';
/**
* Task row from database with optional joined relations
*/
export interface TaskWithRelations extends Tables<'tasks'> {
document?: {
id: string;
document_name: string;
title: string;
description: string | null;
} | null;
}
/**
* Dependency row with joined display_id
*/
export interface DependencyWithDisplayId {
task_id: string;
depends_on_task: {
display_id: string;
} | null;
}
/**
* Task metadata structure
*/
export interface TaskMetadata {
details?: string;
testStrategy?: string;
[key: string]: unknown; // Allow additional fields but be explicit
}
/**
* Database update payload for tasks
*/
export type TaskDatabaseUpdate =
Database['public']['Tables']['tasks']['Update'];
/**
* Configuration for task queries
*/
export interface TaskQueryConfig {
briefId: string;
includeSubtasks?: boolean;
includeDependencies?: boolean;
includeDocument?: boolean;
}
/**
* Result of a task fetch operation
*/
export interface TaskFetchResult {
task: Tables<'tasks'>;
subtasks: Tables<'tasks'>[];
dependencies: Map<string, string[]>;
}
/**
* Task validation errors
*/
export class TaskValidationError extends Error {
constructor(
message: string,
public readonly field: string,
public readonly value: unknown
) {
super(message);
this.name = 'TaskValidationError';
}
}
/**
* Context validation errors
*/
export class ContextValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ContextValidationError';
}
}