mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
feat, standardize abort signal
This commit is contained in:
@@ -52,7 +52,8 @@ export const knowledgeKeys = {
|
||||
export function useKnowledgeItem(sourceId: string | null) {
|
||||
return useQuery<KnowledgeItem>({
|
||||
queryKey: sourceId ? knowledgeKeys.detail(sourceId) : DISABLED_QUERY_KEY,
|
||||
queryFn: () => (sourceId ? knowledgeService.getKnowledgeItem(sourceId) : Promise.reject("No source ID")),
|
||||
queryFn: ({ signal }) =>
|
||||
sourceId ? knowledgeService.getKnowledgeItem(sourceId, signal) : Promise.reject("No source ID"),
|
||||
enabled: !!sourceId,
|
||||
staleTime: STALE_TIMES.normal,
|
||||
});
|
||||
@@ -69,13 +70,17 @@ export function useKnowledgeItemChunks(
|
||||
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
|
||||
return useQuery({
|
||||
queryKey: sourceId ? knowledgeKeys.chunks(sourceId, opts) : DISABLED_QUERY_KEY,
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
sourceId
|
||||
? knowledgeService.getKnowledgeItemChunks(sourceId, {
|
||||
domainFilter: opts?.domain,
|
||||
limit: opts?.limit,
|
||||
offset: opts?.offset,
|
||||
})
|
||||
? knowledgeService.getKnowledgeItemChunks(
|
||||
sourceId,
|
||||
{
|
||||
domainFilter: opts?.domain,
|
||||
limit: opts?.limit,
|
||||
offset: opts?.offset,
|
||||
},
|
||||
signal,
|
||||
)
|
||||
: Promise.reject("No source ID"),
|
||||
enabled: !!sourceId,
|
||||
staleTime: STALE_TIMES.normal,
|
||||
@@ -88,7 +93,8 @@ export function useKnowledgeItemChunks(
|
||||
export function useCodeExamples(sourceId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: sourceId ? knowledgeKeys.codeExamples(sourceId) : DISABLED_QUERY_KEY,
|
||||
queryFn: () => (sourceId ? knowledgeService.getCodeExamples(sourceId) : Promise.reject("No source ID")),
|
||||
queryFn: ({ signal }) =>
|
||||
sourceId ? knowledgeService.getCodeExamples(sourceId, undefined, signal) : Promise.reject("No source ID"),
|
||||
enabled: !!sourceId,
|
||||
staleTime: STALE_TIMES.normal,
|
||||
});
|
||||
@@ -776,7 +782,7 @@ export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) {
|
||||
|
||||
const summaryQuery = useQuery<KnowledgeItemsResponse>({
|
||||
queryKey: knowledgeKeys.summaries(filter),
|
||||
queryFn: () => knowledgeService.getKnowledgeSummaries(filter),
|
||||
queryFn: ({ signal }) => knowledgeService.getKnowledgeSummaries(filter, signal),
|
||||
refetchInterval: hasActiveOperations ? refetchInterval : false, // Poll when ANY operations are active
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: STALE_TIMES.normal, // Consider data stale after 30 seconds
|
||||
@@ -834,12 +840,16 @@ export function useKnowledgeChunks(
|
||||
queryKey: sourceId
|
||||
? knowledgeKeys.chunks(sourceId, { limit: options?.limit, offset: options?.offset })
|
||||
: DISABLED_QUERY_KEY,
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
sourceId
|
||||
? knowledgeService.getKnowledgeItemChunks(sourceId, {
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
})
|
||||
? knowledgeService.getKnowledgeItemChunks(
|
||||
sourceId,
|
||||
{
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
},
|
||||
signal,
|
||||
)
|
||||
: Promise.reject("No source ID"),
|
||||
enabled: options?.enabled !== false && !!sourceId,
|
||||
staleTime: STALE_TIMES.normal,
|
||||
@@ -857,12 +867,16 @@ export function useKnowledgeCodeExamples(
|
||||
queryKey: sourceId
|
||||
? knowledgeKeys.codeExamples(sourceId, { limit: options?.limit, offset: options?.offset })
|
||||
: DISABLED_QUERY_KEY,
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
sourceId
|
||||
? knowledgeService.getCodeExamples(sourceId, {
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
})
|
||||
? knowledgeService.getCodeExamples(
|
||||
sourceId,
|
||||
{
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
},
|
||||
signal,
|
||||
)
|
||||
: Promise.reject("No source ID"),
|
||||
enabled: options?.enabled !== false && !!sourceId,
|
||||
staleTime: STALE_TIMES.normal,
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useInspectorPagination({
|
||||
...knowledgeKeys.detail(sourceId),
|
||||
viewMode === "documents" ? "chunks-infinite" : "code-examples-infinite",
|
||||
],
|
||||
queryFn: ({ pageParam }: { pageParam: unknown }) => {
|
||||
queryFn: ({ pageParam, signal }: { pageParam: unknown; signal?: AbortSignal }) => {
|
||||
const page = Number(pageParam) || 0;
|
||||
const service =
|
||||
viewMode === "documents" ? knowledgeService.getKnowledgeItemChunks : knowledgeService.getCodeExamples;
|
||||
@@ -49,7 +49,7 @@ export function useInspectorPagination({
|
||||
return service(sourceId, {
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
});
|
||||
}, signal);
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const hasMore = (lastPage as ChunksResponse | CodeExamplesResponse)?.has_more;
|
||||
|
||||
@@ -25,7 +25,7 @@ export const knowledgeService = {
|
||||
* Get lightweight summaries of knowledge items
|
||||
* Use this for card displays and frequent updates
|
||||
*/
|
||||
async getKnowledgeSummaries(filter?: KnowledgeItemsFilter): Promise<KnowledgeItemsResponse> {
|
||||
async getKnowledgeSummaries(filter?: KnowledgeItemsFilter, signal?: AbortSignal): Promise<KnowledgeItemsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filter?.page) params.append("page", filter.page.toString());
|
||||
@@ -41,21 +41,22 @@ export const knowledgeService = {
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/api/knowledge-items/summary${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
return callAPIWithETag<KnowledgeItemsResponse>(endpoint);
|
||||
return callAPIWithETag<KnowledgeItemsResponse>(endpoint, { signal });
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific knowledge item
|
||||
*/
|
||||
async getKnowledgeItem(sourceId: string): Promise<KnowledgeItem> {
|
||||
return callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`);
|
||||
async getKnowledgeItem(sourceId: string, signal?: AbortSignal): Promise<KnowledgeItem> {
|
||||
return callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`, { signal });
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a knowledge item
|
||||
*/
|
||||
async deleteKnowledgeItem(sourceId: string): Promise<{ success: boolean; message: string }> {
|
||||
async deleteKnowledgeItem(sourceId: string, signal?: AbortSignal): Promise<{ success: boolean; message: string }> {
|
||||
const response = await callAPIWithETag<{ success: boolean; message: string }>(`/api/knowledge-items/${sourceId}`, {
|
||||
signal,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -68,8 +69,10 @@ export const knowledgeService = {
|
||||
async updateKnowledgeItem(
|
||||
sourceId: string,
|
||||
updates: Partial<KnowledgeItem> & { tags?: string[] },
|
||||
signal?: AbortSignal,
|
||||
): Promise<KnowledgeItem> {
|
||||
const response = await callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`, {
|
||||
signal,
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
@@ -80,8 +83,9 @@ export const knowledgeService = {
|
||||
/**
|
||||
* Start crawling a URL
|
||||
*/
|
||||
async crawlUrl(request: CrawlRequest): Promise<CrawlStartResponse> {
|
||||
async crawlUrl(request: CrawlRequest, signal?: AbortSignal): Promise<CrawlStartResponse> {
|
||||
const response = await callAPIWithETag<CrawlStartResponse>("/api/knowledge-items/crawl", {
|
||||
signal,
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
@@ -92,8 +96,9 @@ export const knowledgeService = {
|
||||
/**
|
||||
* Refresh an existing knowledge item
|
||||
*/
|
||||
async refreshKnowledgeItem(sourceId: string): Promise<RefreshResponse> {
|
||||
async refreshKnowledgeItem(sourceId: string, signal?: AbortSignal): Promise<RefreshResponse> {
|
||||
const response = await callAPIWithETag<RefreshResponse>(`/api/knowledge-items/${sourceId}/refresh`, {
|
||||
signal,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -106,6 +111,7 @@ export const knowledgeService = {
|
||||
async uploadDocument(
|
||||
file: File,
|
||||
metadata: UploadMetadata,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ success: boolean; progressId: string; message: string; filename: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
@@ -129,7 +135,7 @@ export const knowledgeService = {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout for file uploads
|
||||
signal: signal ?? AbortSignal.timeout(30000), // 30 second timeout for file uploads
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -143,8 +149,9 @@ export const knowledgeService = {
|
||||
/**
|
||||
* Stop a running crawl
|
||||
*/
|
||||
async stopCrawl(progressId: string): Promise<{ success: boolean; message: string }> {
|
||||
async stopCrawl(progressId: string, signal?: AbortSignal): Promise<{ success: boolean; message: string }> {
|
||||
return callAPIWithETag<{ success: boolean; message: string }>(`/api/knowledge-items/stop/${progressId}`, {
|
||||
signal,
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
@@ -159,6 +166,7 @@ export const knowledgeService = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChunksResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.domainFilter) {
|
||||
@@ -174,7 +182,7 @@ export const knowledgeService = {
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/api/knowledge-items/${sourceId}/chunks${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
return callAPIWithETag<ChunksResponse>(endpoint);
|
||||
return callAPIWithETag<ChunksResponse>(endpoint, { signal });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -186,6 +194,7 @@ export const knowledgeService = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<CodeExamplesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) {
|
||||
@@ -198,14 +207,15 @@ export const knowledgeService = {
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/api/knowledge-items/${sourceId}/code-examples${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
return callAPIWithETag<CodeExamplesResponse>(endpoint);
|
||||
return callAPIWithETag<CodeExamplesResponse>(endpoint, { signal });
|
||||
},
|
||||
|
||||
/**
|
||||
* Search the knowledge base
|
||||
*/
|
||||
async searchKnowledgeBase(options: SearchOptions): Promise<SearchResultsResponse> {
|
||||
async searchKnowledgeBase(options: SearchOptions, signal?: AbortSignal): Promise<SearchResultsResponse> {
|
||||
return callAPIWithETag<SearchResultsResponse>("/api/knowledge-items/search", {
|
||||
signal,
|
||||
method: "POST",
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
@@ -214,7 +224,7 @@ export const knowledgeService = {
|
||||
/**
|
||||
* Get available knowledge sources
|
||||
*/
|
||||
async getKnowledgeSources(): Promise<KnowledgeSource[]> {
|
||||
return callAPIWithETag<KnowledgeSource[]>("/api/knowledge-items/sources");
|
||||
async getKnowledgeSources(signal?: AbortSignal): Promise<KnowledgeSource[]> {
|
||||
return callAPIWithETag<KnowledgeSource[]>("/api/knowledge-items/sources", { signal });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("useProgressQueries", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockProgress);
|
||||
expect(progressService.getProgress).toHaveBeenCalledWith("progress-123");
|
||||
expect(progressService.getProgress).toHaveBeenCalledWith("progress-123", expect.any(AbortSignal));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ export function useOperationProgress(
|
||||
|
||||
const query = useQuery<ProgressResponse | null>({
|
||||
queryKey: progressId ? progressKeys.detail(progressId) : DISABLED_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!progressId) throw new Error("No progress ID");
|
||||
|
||||
try {
|
||||
const data = await progressService.getProgress(progressId);
|
||||
const data = await progressService.getProgress(progressId, signal);
|
||||
consecutiveNotFound.current = 0; // Reset counter on success
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
@@ -198,7 +198,7 @@ export function useActiveOperations(enabled = false) {
|
||||
|
||||
return useQuery<ActiveOperationsResponse>({
|
||||
queryKey: progressKeys.active(),
|
||||
queryFn: () => progressService.listActiveOperations(),
|
||||
queryFn: ({ signal }) => progressService.listActiveOperations(signal),
|
||||
enabled,
|
||||
refetchInterval: enabled ? refetchInterval : false, // Only poll when explicitly enabled, pause when hidden
|
||||
staleTime: STALE_TIMES.realtime, // Near real-time for active operations
|
||||
@@ -250,9 +250,9 @@ export function useMultipleOperations(
|
||||
const queries = useQueries({
|
||||
queries: progressIds.map((progressId) => ({
|
||||
queryKey: progressKeys.detail(progressId),
|
||||
queryFn: async (): Promise<ProgressResponse | null> => {
|
||||
queryFn: async ({ signal }): Promise<ProgressResponse | null> => {
|
||||
try {
|
||||
const data = await progressService.getProgress(progressId);
|
||||
const data = await progressService.getProgress(progressId, signal);
|
||||
notFoundCounts.current.set(progressId, 0); // Reset counter on success
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -10,15 +10,15 @@ export const progressService = {
|
||||
/**
|
||||
* Get progress for an operation
|
||||
*/
|
||||
async getProgress(progressId: string): Promise<ProgressResponse> {
|
||||
return callAPIWithETag<ProgressResponse>(`/api/progress/${progressId}`);
|
||||
async getProgress(progressId: string, signal?: AbortSignal): Promise<ProgressResponse> {
|
||||
return callAPIWithETag<ProgressResponse>(`/api/progress/${progressId}`, { signal });
|
||||
},
|
||||
|
||||
/**
|
||||
* List all active operations
|
||||
*/
|
||||
async listActiveOperations(): Promise<ActiveOperationsResponse> {
|
||||
async listActiveOperations(signal?: AbortSignal): Promise<ActiveOperationsResponse> {
|
||||
// IMPORTANT: Use trailing slash to avoid FastAPI redirect that breaks in Docker
|
||||
return callAPIWithETag<ActiveOperationsResponse>("/api/progress/");
|
||||
return callAPIWithETag<ActiveOperationsResponse>("/api/progress/", { signal });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,9 +20,9 @@ export const documentKeys = {
|
||||
export function useProjectDocuments(projectId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: projectId ? documentKeys.byProject(projectId) : DISABLED_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!projectId) return [];
|
||||
const project = await projectService.getProject(projectId);
|
||||
const project = await projectService.getProject(projectId, signal);
|
||||
return (project.docs || []) as ProjectDocument[];
|
||||
},
|
||||
enabled: !!projectId,
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useProjects() {
|
||||
|
||||
return useQuery<Project[]>({
|
||||
queryKey: projectKeys.lists(),
|
||||
queryFn: () => projectService.listProjects(),
|
||||
queryFn: ({ signal }) => projectService.listProjects(signal),
|
||||
refetchInterval, // Smart interval based on page visibility/focus
|
||||
refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap)
|
||||
staleTime: STALE_TIMES.normal,
|
||||
@@ -40,7 +40,8 @@ export function useProjectFeatures(projectId: string | undefined) {
|
||||
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
|
||||
return useQuery({
|
||||
queryKey: projectId ? projectKeys.features(projectId) : DISABLED_QUERY_KEY,
|
||||
queryFn: () => (projectId ? projectService.getProjectFeatures(projectId) : Promise.reject("No project ID")),
|
||||
queryFn: ({ signal }) =>
|
||||
projectId ? projectService.getProjectFeatures(projectId, signal) : Promise.reject("No project ID"),
|
||||
enabled: !!projectId,
|
||||
staleTime: STALE_TIMES.normal,
|
||||
});
|
||||
|
||||
@@ -13,10 +13,10 @@ export const projectService = {
|
||||
/**
|
||||
* Get all projects
|
||||
*/
|
||||
async listProjects(): Promise<Project[]> {
|
||||
async listProjects(signal?: AbortSignal): Promise<Project[]> {
|
||||
try {
|
||||
// Fetching projects from API
|
||||
const response = await callAPIWithETag<{ projects: Project[] }>("/api/projects");
|
||||
const response = await callAPIWithETag<{ projects: Project[] }>("/api/projects", { signal });
|
||||
// API response received
|
||||
|
||||
const projects = response.projects || [];
|
||||
@@ -41,6 +41,10 @@ export const projectService = {
|
||||
// All projects processed
|
||||
return processedProjects;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: list projects`);
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to list projects:", error);
|
||||
throw error;
|
||||
}
|
||||
@@ -49,9 +53,9 @@ export const projectService = {
|
||||
/**
|
||||
* Get a specific project by ID
|
||||
*/
|
||||
async getProject(projectId: string): Promise<Project> {
|
||||
async getProject(projectId: string, signal?: AbortSignal): Promise<Project> {
|
||||
try {
|
||||
const project = await callAPIWithETag<Project>(`/api/projects/${projectId}`);
|
||||
const project = await callAPIWithETag<Project>(`/api/projects/${projectId}`, { signal });
|
||||
|
||||
return {
|
||||
...project,
|
||||
@@ -59,6 +63,10 @@ export const projectService = {
|
||||
updated: project.updated || formatRelativeTime(project.updated_at),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: get project ${projectId}`);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Failed to get project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -67,7 +75,10 @@ export const projectService = {
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
async createProject(projectData: CreateProjectRequest): Promise<{
|
||||
async createProject(
|
||||
projectData: CreateProjectRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
project_id: string;
|
||||
project: Project;
|
||||
status: string;
|
||||
@@ -90,6 +101,7 @@ export const projectService = {
|
||||
status: string;
|
||||
message: string;
|
||||
}>("/api/projects", {
|
||||
signal,
|
||||
method: "POST",
|
||||
body: JSON.stringify(validation.data),
|
||||
});
|
||||
@@ -97,6 +109,10 @@ export const projectService = {
|
||||
// Project creation response received
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: create project`);
|
||||
throw error;
|
||||
}
|
||||
console.error("[PROJECT SERVICE] Failed to initiate project creation:", error);
|
||||
if (error instanceof Error) {
|
||||
console.error("[PROJECT SERVICE] Error details:", {
|
||||
@@ -111,7 +127,7 @@ export const projectService = {
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
async updateProject(projectId: string, updates: UpdateProjectRequest): Promise<Project> {
|
||||
async updateProject(projectId: string, updates: UpdateProjectRequest, signal?: AbortSignal): Promise<Project> {
|
||||
// Validate input
|
||||
// Updating project with provided data
|
||||
const validation = validateUpdateProject(updates);
|
||||
@@ -123,6 +139,7 @@ export const projectService = {
|
||||
try {
|
||||
// Sending update request to API
|
||||
const project = await callAPIWithETag<Project>(`/api/projects/${projectId}`, {
|
||||
signal,
|
||||
method: "PUT",
|
||||
body: JSON.stringify(validation.data),
|
||||
});
|
||||
@@ -141,6 +158,10 @@ export const projectService = {
|
||||
|
||||
return processedProject;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: update project ${projectId}`);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Failed to update project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -149,12 +170,17 @@ export const projectService = {
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
async deleteProject(projectId: string): Promise<void> {
|
||||
async deleteProject(projectId: string, signal?: AbortSignal): Promise<void> {
|
||||
try {
|
||||
await callAPIWithETag(`/api/projects/${projectId}`, {
|
||||
signal,
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: delete project ${projectId}`);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Failed to delete project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -163,14 +189,21 @@ export const projectService = {
|
||||
/**
|
||||
* Get features from a project's features JSONB field
|
||||
*/
|
||||
async getProjectFeatures(projectId: string): Promise<{ features: ProjectFeatures; count: number }> {
|
||||
async getProjectFeatures(
|
||||
projectId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ features: ProjectFeatures; count: number }> {
|
||||
try {
|
||||
const response = await callAPIWithETag<{
|
||||
features: ProjectFeatures;
|
||||
count: number;
|
||||
}>(`/api/projects/${projectId}/features`);
|
||||
}>(`/api/projects/${projectId}/features`, { signal });
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: get project features ${projectId}`);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Failed to get features for project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("useTaskQueries", () => {
|
||||
expect(result.current.data).toEqual(mockTasks);
|
||||
});
|
||||
|
||||
expect(taskService.getTasksByProject).toHaveBeenCalledWith("project-123");
|
||||
expect(taskService.getTasksByProject).toHaveBeenCalledWith("project-123", expect.any(AbortSignal));
|
||||
});
|
||||
|
||||
it("should not fetch tasks when projectId is undefined", () => {
|
||||
@@ -153,7 +153,7 @@ describe("useTaskQueries", () => {
|
||||
description: "New Description",
|
||||
status: "todo",
|
||||
assignee: "User",
|
||||
});
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ describe("useTaskQueries", () => {
|
||||
project_id: "project-123",
|
||||
title: "Minimal Task",
|
||||
description: "",
|
||||
});
|
||||
}, undefined);
|
||||
});
|
||||
|
||||
it("should rollback on error", async () => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createOptimisticEntity, replaceOptimisticEntity, removeDuplicateEntities, type OptimisticEntity } from "@/features/shared/optimistic";
|
||||
import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createOptimisticEntity,
|
||||
type OptimisticEntity,
|
||||
removeDuplicateEntities,
|
||||
replaceOptimisticEntity,
|
||||
} from "@/features/shared/optimistic";
|
||||
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns";
|
||||
import { useSmartPolling } from "../../../ui/hooks";
|
||||
import { useToast } from "../../../ui/hooks/useToast";
|
||||
@@ -19,15 +24,19 @@ export const taskKeys = {
|
||||
export function useProjectTasks(projectId: string | undefined, enabled = true) {
|
||||
const { refetchInterval } = useSmartPolling(2000); // 2s active per guideline for real-time task updates
|
||||
|
||||
// Check if there's an update mutation in progress for this project
|
||||
const isMutating = useIsMutating({ mutationKey: ['updateTask', projectId] });
|
||||
|
||||
return useQuery<Task[]>({
|
||||
queryKey: projectId ? taskKeys.byProject(projectId) : DISABLED_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!projectId) throw new Error("No project ID");
|
||||
return taskService.getTasksByProject(projectId);
|
||||
return taskService.getTasksByProject(projectId, signal);
|
||||
},
|
||||
enabled: !!projectId && enabled,
|
||||
refetchInterval, // Smart interval based on page visibility/focus
|
||||
refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap)
|
||||
// Pause polling while mutation is in progress to avoid overwriting optimistic updates
|
||||
refetchInterval: isMutating ? false : refetchInterval,
|
||||
refetchOnWindowFocus: !isMutating, // Don't refetch on focus during mutations
|
||||
staleTime: STALE_TIMES.frequent,
|
||||
});
|
||||
}
|
||||
@@ -36,7 +45,7 @@ export function useProjectTasks(projectId: string | undefined, enabled = true) {
|
||||
export function useTaskCounts() {
|
||||
return useQuery<Awaited<ReturnType<typeof taskService.getTaskCountsForAllProjects>>>({
|
||||
queryKey: taskKeys.counts(),
|
||||
queryFn: () => taskService.getTaskCountsForAllProjects(),
|
||||
queryFn: ({ signal }) => taskService.getTaskCountsForAllProjects(signal),
|
||||
refetchInterval: false, // Don't poll, only refetch manually
|
||||
staleTime: STALE_TIMES.rare,
|
||||
});
|
||||
@@ -48,7 +57,8 @@ export function useCreateTask() {
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (taskData: CreateTaskRequest) => taskService.createTask(taskData),
|
||||
mutationFn: (taskData: CreateTaskRequest, context?: { signal?: AbortSignal }) =>
|
||||
taskService.createTask(taskData, context?.signal),
|
||||
onMutate: async (newTaskData) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(newTaskData.project_id) });
|
||||
@@ -57,20 +67,18 @@ export function useCreateTask() {
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.byProject(newTaskData.project_id));
|
||||
|
||||
// Create optimistic task with stable ID
|
||||
const optimisticTask = createOptimisticEntity<Task>(
|
||||
{
|
||||
project_id: newTaskData.project_id,
|
||||
title: newTaskData.title,
|
||||
description: newTaskData.description || "",
|
||||
status: newTaskData.status ?? "todo",
|
||||
assignee: newTaskData.assignee ?? "User",
|
||||
feature: newTaskData.feature,
|
||||
task_order: newTaskData.task_order ?? 100,
|
||||
priority: newTaskData.priority ?? "medium",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
const optimisticTask = createOptimisticEntity<Task>({
|
||||
project_id: newTaskData.project_id,
|
||||
title: newTaskData.title,
|
||||
description: newTaskData.description || "",
|
||||
status: newTaskData.status ?? "todo",
|
||||
assignee: newTaskData.assignee ?? "User",
|
||||
feature: newTaskData.feature,
|
||||
task_order: newTaskData.task_order ?? 100,
|
||||
priority: newTaskData.priority ?? "medium",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Optimistically add the new task
|
||||
queryClient.setQueryData(taskKeys.byProject(newTaskData.project_id), (old: Task[] | undefined) => {
|
||||
@@ -96,7 +104,7 @@ export function useCreateTask() {
|
||||
(tasks: (Task & Partial<OptimisticEntity>)[] = []) => {
|
||||
const replaced = replaceOptimisticEntity(tasks, context?.optimisticId || "", serverTask);
|
||||
return removeDuplicateEntities(replaced);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Invalidate counts since we have a new task
|
||||
@@ -119,10 +127,13 @@ export function useUpdateTask(projectId: string) {
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation<Task, Error, { taskId: string; updates: UpdateTaskRequest }, { previousTasks?: Task[] }>({
|
||||
mutationFn: ({ taskId, updates }: { taskId: string; updates: UpdateTaskRequest }) =>
|
||||
taskService.updateTask(taskId, updates),
|
||||
mutationKey: ['updateTask', projectId],
|
||||
mutationFn: (
|
||||
{ taskId, updates }: { taskId: string; updates: UpdateTaskRequest },
|
||||
context?: { signal?: AbortSignal },
|
||||
) => taskService.updateTask(taskId, updates, context?.signal),
|
||||
onMutate: async ({ taskId, updates }) => {
|
||||
// Cancel any outgoing refetches
|
||||
// Cancel any outgoing refetches to prevent race conditions
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) });
|
||||
|
||||
// Snapshot the previous value
|
||||
@@ -131,7 +142,7 @@ export function useUpdateTask(projectId: string) {
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<Task[]>(taskKeys.byProject(projectId), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((task) => (task.id === taskId ? { ...task, ...updates } : task));
|
||||
return old.map((task) => (task.id === taskId ? { ...task, ...updates, updated_at: new Date().toISOString() } : task));
|
||||
});
|
||||
|
||||
return { previousTasks };
|
||||
@@ -172,7 +183,7 @@ export function useDeleteTask(projectId: string) {
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation<void, Error, string, { previousTasks?: Task[] }>({
|
||||
mutationFn: (taskId: string) => taskService.deleteTask(taskId),
|
||||
mutationFn: (taskId: string, context?: { signal?: AbortSignal }) => taskService.deleteTask(taskId, context?.signal),
|
||||
onMutate: async (taskId) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) });
|
||||
|
||||
@@ -13,13 +13,17 @@ export const taskService = {
|
||||
/**
|
||||
* Get all tasks for a project
|
||||
*/
|
||||
async getTasksByProject(projectId: string): Promise<Task[]> {
|
||||
async getTasksByProject(projectId: string, signal?: AbortSignal): Promise<Task[]> {
|
||||
try {
|
||||
const tasks = await callAPIWithETag<Task[]>(`/api/projects/${projectId}/tasks`);
|
||||
const tasks = await callAPIWithETag<Task[]>(`/api/projects/${projectId}/tasks`, { signal });
|
||||
|
||||
// Return tasks as-is; UI uses DB status values (todo/doing/review/done)
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: get tasks for project ${projectId}`);
|
||||
throw error; // Let TanStack Query handle the cancellation
|
||||
}
|
||||
console.error(`Failed to get tasks for project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -28,11 +32,15 @@ export const taskService = {
|
||||
/**
|
||||
* Get a specific task by ID
|
||||
*/
|
||||
async getTask(taskId: string): Promise<Task> {
|
||||
async getTask(taskId: string, signal?: AbortSignal): Promise<Task> {
|
||||
try {
|
||||
const task = await callAPIWithETag<Task>(`/api/tasks/${taskId}`);
|
||||
const task = await callAPIWithETag<Task>(`/api/tasks/${taskId}`, { signal });
|
||||
return task;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: get task ${taskId}`);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Failed to get task ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -41,7 +49,7 @@ export const taskService = {
|
||||
/**
|
||||
* Create a new task
|
||||
*/
|
||||
async createTask(taskData: CreateTaskRequest): Promise<Task> {
|
||||
async createTask(taskData: CreateTaskRequest, signal?: AbortSignal): Promise<Task> {
|
||||
// Validate input
|
||||
const validation = validateCreateTask(taskData);
|
||||
if (!validation.success) {
|
||||
@@ -54,12 +62,17 @@ export const taskService = {
|
||||
|
||||
// Backend returns { message: string, task: Task } for mutations
|
||||
const response = await callAPIWithETag<{ message: string; task: Task }>("/api/tasks", {
|
||||
signal,
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
return response.task;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: create task`);
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to create task:", error);
|
||||
throw error;
|
||||
}
|
||||
@@ -68,7 +81,7 @@ export const taskService = {
|
||||
/**
|
||||
* Update an existing task
|
||||
*/
|
||||
async updateTask(taskId: string, updates: UpdateTaskRequest): Promise<Task> {
|
||||
async updateTask(taskId: string, updates: UpdateTaskRequest, signal?: AbortSignal): Promise<Task> {
|
||||
// Validate input
|
||||
const validation = validateUpdateTask(updates);
|
||||
if (!validation.success) {
|
||||
@@ -78,12 +91,17 @@ export const taskService = {
|
||||
try {
|
||||
// Backend returns { message: string, task: Task } for mutations
|
||||
const response = await callAPIWithETag<{ message: string; task: Task }>(`/api/tasks/${taskId}`, {
|
||||
signal,
|
||||
method: "PUT",
|
||||
body: JSON.stringify(validation.data),
|
||||
});
|
||||
|
||||
return response.task;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: update task ${taskId}`);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Failed to update task ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -92,7 +110,7 @@ export const taskService = {
|
||||
/**
|
||||
* Update task status (for drag & drop operations)
|
||||
*/
|
||||
async updateTaskStatus(taskId: string, status: DatabaseTaskStatus): Promise<Task> {
|
||||
async updateTaskStatus(taskId: string, status: DatabaseTaskStatus, signal?: AbortSignal): Promise<Task> {
|
||||
// Validate input
|
||||
const validation = validateUpdateTaskStatus({
|
||||
task_id: taskId,
|
||||
@@ -106,12 +124,17 @@ export const taskService = {
|
||||
// Use the standard update task endpoint with JSON body
|
||||
// Backend returns { message: string, task: Task } for mutations
|
||||
const response = await callAPIWithETag<{ message: string; task: Task }>(`/api/tasks/${taskId}`, {
|
||||
signal,
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
return response.task;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: update task status ${taskId}`);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Failed to update task status ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -120,12 +143,17 @@ export const taskService = {
|
||||
/**
|
||||
* Delete a task
|
||||
*/
|
||||
async deleteTask(taskId: string): Promise<void> {
|
||||
async deleteTask(taskId: string, signal?: AbortSignal): Promise<void> {
|
||||
try {
|
||||
await callAPIWithETag<void>(`/api/tasks/${taskId}`, {
|
||||
signal,
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: delete task ${taskId}`);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Failed to delete task ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -134,7 +162,12 @@ export const taskService = {
|
||||
/**
|
||||
* Update task order for better drag-and-drop support
|
||||
*/
|
||||
async updateTaskOrder(taskId: string, newOrder: number, newStatus?: DatabaseTaskStatus): Promise<Task> {
|
||||
async updateTaskOrder(
|
||||
taskId: string,
|
||||
newOrder: number,
|
||||
newStatus?: DatabaseTaskStatus,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Task> {
|
||||
try {
|
||||
const updates: UpdateTaskRequest = {
|
||||
task_order: newOrder,
|
||||
@@ -144,10 +177,14 @@ export const taskService = {
|
||||
updates.status = newStatus;
|
||||
}
|
||||
|
||||
const task = await this.updateTask(taskId, updates);
|
||||
const task = await this.updateTask(taskId, updates, signal);
|
||||
|
||||
return task;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: update task order for ${taskId}`);
|
||||
throw error;
|
||||
}
|
||||
console.error(`Failed to update task order for ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -171,11 +208,15 @@ export const taskService = {
|
||||
* Get task counts for all projects in a single batch request
|
||||
* Optimized endpoint to avoid N+1 query problem
|
||||
*/
|
||||
async getTaskCountsForAllProjects(): Promise<Record<string, TaskCounts>> {
|
||||
async getTaskCountsForAllProjects(signal?: AbortSignal): Promise<Record<string, TaskCounts>> {
|
||||
try {
|
||||
const response = await callAPIWithETag<Record<string, TaskCounts>>("/api/projects/task-counts");
|
||||
const response = await callAPIWithETag<Record<string, TaskCounts>>("/api/projects/task-counts", { signal });
|
||||
return response || {};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.debug(`Request cancelled: get task counts for all projects`);
|
||||
throw error;
|
||||
}
|
||||
console.error("Failed to get task counts for all projects:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ describe("taskService", () => {
|
||||
|
||||
const result = await taskService.getTasksByProject(projectId);
|
||||
|
||||
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/projects/${projectId}/tasks`);
|
||||
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/projects/${projectId}/tasks`, { signal: undefined });
|
||||
expect(result).toEqual(mockTasks);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user