feat: implement tm list remote (#1185)
This commit is contained in:
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* @fileoverview API-based storage implementation for Hamster integration
|
||||
* This provides storage via REST API instead of local file system
|
||||
* @fileoverview API-based storage implementation using repository pattern
|
||||
* This provides storage via repository abstraction for flexibility
|
||||
*/
|
||||
|
||||
import type {
|
||||
IStorage,
|
||||
StorageStats
|
||||
} from '../interfaces/storage.interface.js';
|
||||
import type { Task, TaskMetadata } from '../types/index.js';
|
||||
import type { Task, TaskMetadata, TaskTag } from '../types/index.js';
|
||||
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
||||
import { TaskRepository } from '../repositories/task-repository.interface.js';
|
||||
import { SupabaseTaskRepository } from '../repositories/supabase-task-repository.js';
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { AuthManager } from '../auth/auth-manager.js';
|
||||
|
||||
/**
|
||||
* API storage configuration
|
||||
*/
|
||||
export interface ApiStorageConfig {
|
||||
/** API endpoint base URL */
|
||||
endpoint: string;
|
||||
/** Access token for authentication */
|
||||
accessToken: string;
|
||||
/** Optional project ID */
|
||||
projectId?: string;
|
||||
/** Request timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Supabase client instance */
|
||||
supabaseClient?: SupabaseClient;
|
||||
/** Custom repository implementation */
|
||||
repository?: TaskRepository;
|
||||
/** Project ID for scoping */
|
||||
projectId: string;
|
||||
/** Enable request retries */
|
||||
enableRetry?: boolean;
|
||||
/** Maximum retry attempts */
|
||||
@@ -29,64 +31,58 @@ export interface ApiStorageConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* API response wrapper
|
||||
*/
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ApiStorage implementation for Hamster integration
|
||||
* Fetches and stores tasks via REST API
|
||||
* ApiStorage implementation using repository pattern
|
||||
* Provides flexibility to swap between different backend implementations
|
||||
*/
|
||||
export class ApiStorage implements IStorage {
|
||||
private readonly config: Required<ApiStorageConfig>;
|
||||
private readonly repository: TaskRepository;
|
||||
private readonly projectId: string;
|
||||
private readonly enableRetry: boolean;
|
||||
private readonly maxRetries: number;
|
||||
private initialized = false;
|
||||
private tagsCache: Map<string, TaskTag> = new Map();
|
||||
|
||||
constructor(config: ApiStorageConfig) {
|
||||
this.validateConfig(config);
|
||||
|
||||
this.config = {
|
||||
endpoint: config.endpoint.replace(/\/$/, ''), // Remove trailing slash
|
||||
accessToken: config.accessToken,
|
||||
projectId: config.projectId || 'default',
|
||||
timeout: config.timeout || 30000,
|
||||
enableRetry: config.enableRetry ?? true,
|
||||
maxRetries: config.maxRetries || 3
|
||||
};
|
||||
// Use provided repository or create Supabase repository
|
||||
if (config.repository) {
|
||||
this.repository = config.repository;
|
||||
} else if (config.supabaseClient) {
|
||||
// TODO: SupabaseTaskRepository doesn't implement all TaskRepository methods yet
|
||||
// Cast for now until full implementation is complete
|
||||
this.repository = new SupabaseTaskRepository(
|
||||
config.supabaseClient
|
||||
) as unknown as TaskRepository;
|
||||
} else {
|
||||
throw new TaskMasterError(
|
||||
'Either repository or supabaseClient must be provided',
|
||||
ERROR_CODES.MISSING_CONFIGURATION
|
||||
);
|
||||
}
|
||||
|
||||
this.projectId = config.projectId;
|
||||
this.enableRetry = config.enableRetry ?? true;
|
||||
this.maxRetries = config.maxRetries ?? 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API storage configuration
|
||||
*/
|
||||
private validateConfig(config: ApiStorageConfig): void {
|
||||
if (!config.endpoint) {
|
||||
if (!config.projectId) {
|
||||
throw new TaskMasterError(
|
||||
'API endpoint is required for API storage',
|
||||
'Project ID is required for API storage',
|
||||
ERROR_CODES.MISSING_CONFIGURATION
|
||||
);
|
||||
}
|
||||
|
||||
if (!config.accessToken) {
|
||||
if (!config.repository && !config.supabaseClient) {
|
||||
throw new TaskMasterError(
|
||||
'Access token is required for API storage',
|
||||
'Either repository or supabaseClient must be provided',
|
||||
ERROR_CODES.MISSING_CONFIGURATION
|
||||
);
|
||||
}
|
||||
|
||||
// Validate endpoint URL format
|
||||
try {
|
||||
new URL(config.endpoint);
|
||||
} catch {
|
||||
throw new TaskMasterError(
|
||||
'Invalid API endpoint URL',
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
{ endpoint: config.endpoint }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,8 +92,8 @@ export class ApiStorage implements IStorage {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
// Verify API connectivity
|
||||
await this.verifyConnection();
|
||||
// Load initial tags
|
||||
await this.loadTagsIntoCache();
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
@@ -110,39 +106,71 @@ export class ApiStorage implements IStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify API connection
|
||||
* Load tags into cache
|
||||
* In our API-based system, "tags" represent briefs
|
||||
*/
|
||||
private async verifyConnection(): Promise<void> {
|
||||
const response = await this.makeRequest<{ status: string }>('/health');
|
||||
private async loadTagsIntoCache(): Promise<void> {
|
||||
try {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const context = authManager.getContext();
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`API health check failed: ${response.error}`);
|
||||
// If we have a selected brief, create a virtual "tag" for it
|
||||
if (context?.briefId) {
|
||||
// Create a virtual tag representing the current brief
|
||||
const briefTag: TaskTag = {
|
||||
name: context.briefId,
|
||||
tasks: [], // Will be populated when tasks are loaded
|
||||
metadata: {
|
||||
briefId: context.briefId,
|
||||
briefName: context.briefName,
|
||||
organizationId: context.orgId
|
||||
}
|
||||
};
|
||||
|
||||
this.tagsCache.clear();
|
||||
this.tagsCache.set(context.briefId, briefTag);
|
||||
}
|
||||
} catch (error) {
|
||||
// If no brief is selected, that's okay - user needs to select one first
|
||||
console.debug('No brief selected, starting with empty cache');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tasks from API
|
||||
* In our system, the tag parameter represents a brief ID
|
||||
*/
|
||||
async loadTasks(tag?: string): Promise<Task[]> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const endpoint = tag
|
||||
? `/projects/${this.config.projectId}/tasks?tag=${encodeURIComponent(tag)}`
|
||||
: `/projects/${this.config.projectId}/tasks`;
|
||||
const authManager = AuthManager.getInstance();
|
||||
const context = authManager.getContext();
|
||||
|
||||
const response = await this.makeRequest<{ tasks: Task[] }>(endpoint);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to load tasks');
|
||||
// If no brief is selected in context, throw an error
|
||||
if (!context?.briefId) {
|
||||
throw new Error(
|
||||
'No brief selected. Please select a brief first using: tm context brief <brief-id>'
|
||||
);
|
||||
}
|
||||
|
||||
return response.data?.tasks || [];
|
||||
// Load tasks from the current brief context
|
||||
const tasks = await this.retryOperation(() =>
|
||||
this.repository.getTasks(this.projectId)
|
||||
);
|
||||
|
||||
// Update the tag cache with the loaded task IDs
|
||||
const briefTag = this.tagsCache.get(context.briefId);
|
||||
if (briefTag) {
|
||||
briefTag.tasks = tasks.map((task) => task.id);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to load tasks from API',
|
||||
ERROR_CODES.STORAGE_ERROR,
|
||||
{ operation: 'loadTasks', tag },
|
||||
{ operation: 'loadTasks', tag, context: 'brief-based loading' },
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
@@ -155,15 +183,29 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const endpoint = tag
|
||||
? `/projects/${this.config.projectId}/tasks?tag=${encodeURIComponent(tag)}`
|
||||
: `/projects/${this.config.projectId}/tasks`;
|
||||
if (tag) {
|
||||
// Update tag with task IDs
|
||||
const tagData = this.tagsCache.get(tag) || {
|
||||
name: tag,
|
||||
tasks: [],
|
||||
metadata: {}
|
||||
};
|
||||
tagData.tasks = tasks.map((t) => t.id);
|
||||
|
||||
const response = await this.makeRequest(endpoint, 'PUT', { tasks });
|
||||
// Save or update tag
|
||||
if (this.tagsCache.has(tag)) {
|
||||
await this.repository.updateTag(this.projectId, tag, tagData);
|
||||
} else {
|
||||
await this.repository.createTag(this.projectId, tagData);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to save tasks');
|
||||
this.tagsCache.set(tag, tagData);
|
||||
}
|
||||
|
||||
// Save tasks using bulk operation
|
||||
await this.retryOperation(() =>
|
||||
this.repository.bulkCreateTasks(this.projectId, tasks)
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to save tasks to API',
|
||||
@@ -181,20 +223,17 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const endpoint = tag
|
||||
? `/projects/${this.config.projectId}/tasks/${taskId}?tag=${encodeURIComponent(tag)}`
|
||||
: `/projects/${this.config.projectId}/tasks/${taskId}`;
|
||||
|
||||
const response = await this.makeRequest<{ task: Task }>(endpoint);
|
||||
|
||||
if (!response.success) {
|
||||
if (response.error?.includes('not found')) {
|
||||
if (tag) {
|
||||
// Check if task is in tag
|
||||
const tagData = this.tagsCache.get(tag);
|
||||
if (!tagData || !tagData.tasks.includes(taskId)) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(response.error || 'Failed to load task');
|
||||
}
|
||||
|
||||
return response.data?.task || null;
|
||||
return await this.retryOperation(() =>
|
||||
this.repository.getTask(this.projectId, taskId)
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to load task from API',
|
||||
@@ -212,14 +251,26 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const endpoint = tag
|
||||
? `/projects/${this.config.projectId}/tasks/${task.id}?tag=${encodeURIComponent(tag)}`
|
||||
: `/projects/${this.config.projectId}/tasks/${task.id}`;
|
||||
// Check if task exists
|
||||
const existing = await this.repository.getTask(this.projectId, task.id);
|
||||
|
||||
const response = await this.makeRequest(endpoint, 'PUT', { task });
|
||||
if (existing) {
|
||||
await this.retryOperation(() =>
|
||||
this.repository.updateTask(this.projectId, task.id, task)
|
||||
);
|
||||
} else {
|
||||
await this.retryOperation(() =>
|
||||
this.repository.createTask(this.projectId, task)
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to save task');
|
||||
// Update tag if specified
|
||||
if (tag) {
|
||||
const tagData = this.tagsCache.get(tag);
|
||||
if (tagData && !tagData.tasks.includes(task.id)) {
|
||||
tagData.tasks.push(task.id);
|
||||
await this.repository.updateTag(this.projectId, tag, tagData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
@@ -238,14 +289,17 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const endpoint = tag
|
||||
? `/projects/${this.config.projectId}/tasks/${taskId}?tag=${encodeURIComponent(tag)}`
|
||||
: `/projects/${this.config.projectId}/tasks/${taskId}`;
|
||||
await this.retryOperation(() =>
|
||||
this.repository.deleteTask(this.projectId, taskId)
|
||||
);
|
||||
|
||||
const response = await this.makeRequest(endpoint, 'DELETE');
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to delete task');
|
||||
// Remove from tag if specified
|
||||
if (tag) {
|
||||
const tagData = this.tagsCache.get(tag);
|
||||
if (tagData) {
|
||||
tagData.tasks = tagData.tasks.filter((id) => id !== taskId);
|
||||
await this.repository.updateTag(this.projectId, tag, tagData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
@@ -258,21 +312,24 @@ export class ApiStorage implements IStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* List available tags
|
||||
* List available tags (briefs in our system)
|
||||
*/
|
||||
async listTags(): Promise<string[]> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<{ tags: string[] }>(
|
||||
`/projects/${this.config.projectId}/tags`
|
||||
);
|
||||
const authManager = AuthManager.getInstance();
|
||||
const context = authManager.getContext();
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to list tags');
|
||||
// In our API-based system, we only have one "tag" at a time - the current brief
|
||||
if (context?.briefId) {
|
||||
// Ensure the current brief is in our cache
|
||||
await this.loadTagsIntoCache();
|
||||
return [context.briefId];
|
||||
}
|
||||
|
||||
return response.data?.tags || [];
|
||||
// No brief selected, return empty array
|
||||
return [];
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to list tags from API',
|
||||
@@ -290,19 +347,15 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const endpoint = tag
|
||||
? `/projects/${this.config.projectId}/metadata?tag=${encodeURIComponent(tag)}`
|
||||
: `/projects/${this.config.projectId}/metadata`;
|
||||
|
||||
const response = await this.makeRequest<{ metadata: TaskMetadata }>(
|
||||
endpoint
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
return null;
|
||||
if (tag) {
|
||||
const tagData = this.tagsCache.get(tag);
|
||||
return (tagData?.metadata as TaskMetadata) || null;
|
||||
}
|
||||
|
||||
return response.data?.metadata || null;
|
||||
// Return global metadata if no tag specified
|
||||
// This could be stored in a special system tag
|
||||
const systemTag = await this.repository.getTag(this.projectId, '_system');
|
||||
return (systemTag?.metadata as TaskMetadata) || null;
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to load metadata from API',
|
||||
@@ -320,14 +373,38 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const endpoint = tag
|
||||
? `/projects/${this.config.projectId}/metadata?tag=${encodeURIComponent(tag)}`
|
||||
: `/projects/${this.config.projectId}/metadata`;
|
||||
if (tag) {
|
||||
const tagData = this.tagsCache.get(tag) || {
|
||||
name: tag,
|
||||
tasks: [],
|
||||
metadata: {}
|
||||
};
|
||||
tagData.metadata = metadata as any;
|
||||
|
||||
const response = await this.makeRequest(endpoint, 'PUT', { metadata });
|
||||
if (this.tagsCache.has(tag)) {
|
||||
await this.repository.updateTag(this.projectId, tag, tagData);
|
||||
} else {
|
||||
await this.repository.createTag(this.projectId, tagData);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to save metadata');
|
||||
this.tagsCache.set(tag, tagData);
|
||||
} else {
|
||||
// Save to system tag
|
||||
const systemTag: TaskTag = {
|
||||
name: '_system',
|
||||
tasks: [],
|
||||
metadata: metadata as any
|
||||
};
|
||||
|
||||
const existing = await this.repository.getTag(
|
||||
this.projectId,
|
||||
'_system'
|
||||
);
|
||||
if (existing) {
|
||||
await this.repository.updateTag(this.projectId, '_system', systemTag);
|
||||
} else {
|
||||
await this.repository.createTag(this.projectId, systemTag);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
@@ -358,14 +435,30 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
// First load existing tasks
|
||||
const existingTasks = await this.loadTasks(tag);
|
||||
// Use bulk create - repository should handle duplicates
|
||||
await this.retryOperation(() =>
|
||||
this.repository.bulkCreateTasks(this.projectId, tasks)
|
||||
);
|
||||
|
||||
// Append new tasks
|
||||
const allTasks = [...existingTasks, ...tasks];
|
||||
// Update tag if specified
|
||||
if (tag) {
|
||||
const tagData = this.tagsCache.get(tag) || {
|
||||
name: tag,
|
||||
tasks: [],
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
// Save all tasks
|
||||
await this.saveTasks(allTasks, tag);
|
||||
const newTaskIds = tasks.map((t) => t.id);
|
||||
tagData.tasks = [...new Set([...tagData.tasks, ...newTaskIds])];
|
||||
|
||||
if (this.tagsCache.has(tag)) {
|
||||
await this.repository.updateTag(this.projectId, tag, tagData);
|
||||
} else {
|
||||
await this.repository.createTag(this.projectId, tagData);
|
||||
}
|
||||
|
||||
this.tagsCache.set(tag, tagData);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to append tasks to API',
|
||||
@@ -387,18 +480,9 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
// Load the task
|
||||
const task = await this.loadTask(taskId, tag);
|
||||
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
|
||||
// Merge updates
|
||||
const updatedTask = { ...task, ...updates, id: taskId };
|
||||
|
||||
// Save updated task
|
||||
await this.saveTask(updatedTask, tag);
|
||||
await this.retryOperation(() =>
|
||||
this.repository.updateTask(this.projectId, taskId, updates)
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to update task via API',
|
||||
@@ -423,14 +507,11 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest(
|
||||
`/projects/${this.config.projectId}/tags/${encodeURIComponent(tag)}`,
|
||||
'DELETE'
|
||||
await this.retryOperation(() =>
|
||||
this.repository.deleteTag(this.projectId, tag)
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to delete tag');
|
||||
}
|
||||
this.tagsCache.delete(tag);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to delete tag via API',
|
||||
@@ -448,15 +529,21 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest(
|
||||
`/projects/${this.config.projectId}/tags/${encodeURIComponent(oldTag)}/rename`,
|
||||
'POST',
|
||||
{ newTag }
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to rename tag');
|
||||
const tagData = this.tagsCache.get(oldTag);
|
||||
if (!tagData) {
|
||||
throw new Error(`Tag ${oldTag} not found`);
|
||||
}
|
||||
|
||||
// Create new tag with same data
|
||||
const newTagData = { ...tagData, name: newTag };
|
||||
await this.repository.createTag(this.projectId, newTagData);
|
||||
|
||||
// Delete old tag
|
||||
await this.repository.deleteTag(this.projectId, oldTag);
|
||||
|
||||
// Update cache
|
||||
this.tagsCache.delete(oldTag);
|
||||
this.tagsCache.set(newTag, newTagData);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to rename tag via API',
|
||||
@@ -474,15 +561,17 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest(
|
||||
`/projects/${this.config.projectId}/tags/${encodeURIComponent(sourceTag)}/copy`,
|
||||
'POST',
|
||||
{ targetTag }
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to copy tag');
|
||||
const sourceData = this.tagsCache.get(sourceTag);
|
||||
if (!sourceData) {
|
||||
throw new Error(`Source tag ${sourceTag} not found`);
|
||||
}
|
||||
|
||||
// Create new tag with copied data
|
||||
const targetData = { ...sourceData, name: targetTag };
|
||||
await this.repository.createTag(this.projectId, targetData);
|
||||
|
||||
// Update cache
|
||||
this.tagsCache.set(targetTag, targetData);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to copy tag via API',
|
||||
@@ -500,24 +589,22 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<{
|
||||
stats: StorageStats;
|
||||
}>(`/projects/${this.config.projectId}/stats`);
|
||||
const tasks = await this.repository.getTasks(this.projectId);
|
||||
const tags = await this.repository.getTags(this.projectId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to get stats');
|
||||
}
|
||||
const tagStats = tags.map((tag) => ({
|
||||
tag: tag.name,
|
||||
taskCount: tag.tasks.length,
|
||||
lastModified: new Date().toISOString() // TODO: Get actual last modified from tag data
|
||||
}));
|
||||
|
||||
// Return stats or default values
|
||||
return (
|
||||
response.data?.stats || {
|
||||
totalTasks: 0,
|
||||
totalTags: 0,
|
||||
storageSize: 0,
|
||||
lastModified: new Date().toISOString(),
|
||||
tagStats: []
|
||||
}
|
||||
);
|
||||
return {
|
||||
totalTasks: tasks.length,
|
||||
totalTags: tags.length,
|
||||
storageSize: 0, // Not applicable for API storage
|
||||
lastModified: new Date().toISOString(),
|
||||
tagStats
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to get stats from API',
|
||||
@@ -535,16 +622,15 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<{ backupId: string }>(
|
||||
`/projects/${this.config.projectId}/backup`,
|
||||
'POST'
|
||||
);
|
||||
// Export all data
|
||||
await this.repository.getTasks(this.projectId);
|
||||
await this.repository.getTags(this.projectId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to create backup');
|
||||
}
|
||||
|
||||
return response.data?.backupId || 'unknown';
|
||||
// TODO: In a real implementation, this would:
|
||||
// 1. Create backup data structure with tasks and tags
|
||||
// 2. Save the backup to a storage service
|
||||
// For now, return a backup identifier
|
||||
return `backup-${this.projectId}-${Date.now()}`;
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to create backup via API',
|
||||
@@ -558,27 +644,16 @@ export class ApiStorage implements IStorage {
|
||||
/**
|
||||
* Restore from backup
|
||||
*/
|
||||
async restore(backupPath: string): Promise<void> {
|
||||
async restore(backupId: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest(
|
||||
`/projects/${this.config.projectId}/restore`,
|
||||
'POST',
|
||||
{ backupId: backupPath }
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to restore backup');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to restore backup via API',
|
||||
ERROR_CODES.STORAGE_ERROR,
|
||||
{ operation: 'restore', backupPath },
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
// This would restore from a backup service
|
||||
// Implementation depends on backup strategy
|
||||
throw new TaskMasterError(
|
||||
'Restore not implemented for API storage',
|
||||
ERROR_CODES.NOT_IMPLEMENTED,
|
||||
{ operation: 'restore', backupId }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -588,14 +663,23 @@ export class ApiStorage implements IStorage {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest(
|
||||
`/projects/${this.config.projectId}/clear`,
|
||||
'POST'
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to clear data');
|
||||
// Delete all tasks
|
||||
const tasks = await this.repository.getTasks(this.projectId);
|
||||
if (tasks.length > 0) {
|
||||
await this.repository.bulkDeleteTasks(
|
||||
this.projectId,
|
||||
tasks.map((t) => t.id)
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all tags
|
||||
const tags = await this.repository.getTags(this.projectId);
|
||||
for (const tag of tags) {
|
||||
await this.repository.deleteTag(this.projectId, tag.name);
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
this.tagsCache.clear();
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to clear data via API',
|
||||
@@ -611,6 +695,7 @@ export class ApiStorage implements IStorage {
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.tagsCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -623,102 +708,21 @@ export class ApiStorage implements IStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request to API
|
||||
* Retry an operation with exponential backoff
|
||||
*/
|
||||
private async makeRequest<T>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
body?: unknown
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = `${this.config.endpoint}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
private async retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
attempt: number = 1
|
||||
): Promise<T> {
|
||||
try {
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal: controller.signal
|
||||
};
|
||||
|
||||
if (body && (method === 'POST' || method === 'PUT')) {
|
||||
options.body = JSON.stringify(body);
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (this.enableRetry && attempt < this.maxRetries) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.retryOperation(operation, attempt + 1);
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < this.config.maxRetries) {
|
||||
attempt++;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true, data: data as T };
|
||||
}
|
||||
|
||||
// Handle specific error codes
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Authentication failed - check access token'
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Resource not found'
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
// Rate limited - retry with backoff
|
||||
if (this.config.enableRetry && attempt < this.config.maxRetries) {
|
||||
await this.delay(Math.pow(2, attempt) * 1000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const errorData = data as any;
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
errorData.error ||
|
||||
errorData.message ||
|
||||
`HTTP ${response.status}: ${response.statusText}`
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
// Retry on network errors
|
||||
if (this.config.enableRetry && attempt < this.config.maxRetries) {
|
||||
await this.delay(Math.pow(2, attempt) * 1000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
return {
|
||||
success: false,
|
||||
error: lastError?.message || 'Request failed after retries'
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay helper for retries
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ApiStorage } from './api-storage.js';
|
||||
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
||||
import { AuthManager } from '../auth/auth-manager.js';
|
||||
import { getLogger } from '../logger/index.js';
|
||||
import { SupabaseAuthClient } from '../clients/supabase-client.js';
|
||||
|
||||
/**
|
||||
* Factory for creating storage implementations based on configuration
|
||||
@@ -148,29 +149,13 @@ export class StorageFactory {
|
||||
* Create API storage implementation
|
||||
*/
|
||||
private static createApiStorage(config: Partial<IConfiguration>): ApiStorage {
|
||||
const { apiEndpoint, apiAccessToken } = config.storage || {};
|
||||
|
||||
if (!apiEndpoint) {
|
||||
throw new TaskMasterError(
|
||||
'API endpoint is required for API storage',
|
||||
ERROR_CODES.MISSING_CONFIGURATION,
|
||||
{ storageType: 'api' }
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiAccessToken) {
|
||||
throw new TaskMasterError(
|
||||
'API access token is required for API storage',
|
||||
ERROR_CODES.MISSING_CONFIGURATION,
|
||||
{ storageType: 'api' }
|
||||
);
|
||||
}
|
||||
// Use our SupabaseAuthClient instead of creating a raw Supabase client
|
||||
const supabaseAuthClient = new SupabaseAuthClient();
|
||||
const supabaseClient = supabaseAuthClient.getClient();
|
||||
|
||||
return new ApiStorage({
|
||||
endpoint: apiEndpoint,
|
||||
accessToken: apiAccessToken,
|
||||
projectId: config.projectPath,
|
||||
timeout: config.retry?.requestTimeout,
|
||||
supabaseClient,
|
||||
projectId: config.projectPath || '',
|
||||
enableRetry: config.retry?.retryOnNetworkError,
|
||||
maxRetries: config.retry?.retryAttempts
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user