Skip to content
3 changes: 2 additions & 1 deletion PRPs/ai_docs/API_NAMING_CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ GET /api/agent-chat/sessions/{id}/messages - Chat messages
### Database Types (from backend)
```typescript
type DatabaseTaskStatus = 'todo' | 'doing' | 'review' | 'done';
type Assignee = 'User' | 'Archon' | 'AI IDE Agent';
type Assignee = string; // Flexible string to support any agent name
// Common values: 'User', 'Archon', 'Coding Agent'
```

### Request/Response Types
Expand Down
227 changes: 227 additions & 0 deletions PRPs/ai_docs/QUERY_PATTERNS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# TanStack Query Patterns Guide

This guide documents the standardized patterns for using TanStack Query v5 in the Archon frontend.

## Core Principles

1. **Feature Ownership**: Each feature owns its query keys in `{feature}/hooks/use{Feature}Queries.ts`
2. **Consistent Patterns**: Always use shared patterns from `shared/queryPatterns.ts`
3. **No Hardcoded Values**: Never hardcode stale times or disabled keys
4. **Mirror Backend API**: Query keys should exactly match backend API structure

## Query Key Factory Pattern

Every feature MUST implement a query key factory following this pattern:

```typescript
// features/{feature}/hooks/use{Feature}Queries.ts
export const featureKeys = {
all: ["feature"] as const, // Base key for the domain
lists: () => [...featureKeys.all, "list"] as const, // For list endpoints
detail: (id: string) => [...featureKeys.all, "detail", id] as const, // For single item
// Add more as needed following backend routes
};
```

### Examples from Codebase

```typescript
// Projects - Simple hierarchy
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
detail: (id: string) => [...projectKeys.all, "detail", id] as const,
features: (id: string) => [...projectKeys.all, id, "features"] as const,
};

// Tasks - Dual nature (global and project-scoped)
export const taskKeys = {
all: ["tasks"] as const,
lists: () => [...taskKeys.all, "list"] as const, // /api/tasks
detail: (id: string) => [...taskKeys.all, "detail", id] as const,
byProject: (projectId: string) => ["projects", projectId, "tasks"] as const, // /api/projects/{id}/tasks
counts: () => [...taskKeys.all, "counts"] as const,
};
```

## Shared Patterns Usage

### Import Required Patterns

```typescript
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/queryPatterns";
```

### Disabled Queries

Always use `DISABLED_QUERY_KEY` when a query should not execute:

```typescript
// ✅ CORRECT
queryKey: projectId ? projectKeys.detail(projectId) : DISABLED_QUERY_KEY,

// ❌ WRONG - Don't create custom disabled keys
queryKey: projectId ? projectKeys.detail(projectId) : ["projects-undefined"],
```

### Stale Times

Always use `STALE_TIMES` constants for cache configuration:

```typescript
// ✅ CORRECT
staleTime: STALE_TIMES.normal, // 30 seconds
staleTime: STALE_TIMES.frequent, // 5 seconds
staleTime: STALE_TIMES.instant, // 0 - always fresh

// ❌ WRONG - Don't hardcode times
staleTime: 30000,
staleTime: 0,
```

#### STALE_TIMES Reference

- `instant: 0` - Always fresh (real-time data like active progress)
- `realtime: 3_000` - 3 seconds (near real-time updates)
- `frequent: 5_000` - 5 seconds (frequently changing data)
- `normal: 30_000` - 30 seconds (standard cache time)
- `rare: 300_000` - 5 minutes (rarely changing config)
- `static: Infinity` - Never stale (settings, auth)

## Complete Hook Pattern

```typescript
export function useFeatureDetail(id: string | undefined) {
return useQuery({
queryKey: id ? featureKeys.detail(id) : DISABLED_QUERY_KEY,
queryFn: () => id
? featureService.getFeatureById(id)
: Promise.reject("No ID provided"),
enabled: !!id,
staleTime: STALE_TIMES.normal,
});
}
```

## Mutations with Optimistic Updates

```typescript
export function useCreateFeature() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (data: CreateFeatureRequest) => featureService.create(data),

onMutate: async (newData) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: featureKeys.lists() });

// Snapshot for rollback
const previous = queryClient.getQueryData(featureKeys.lists());

// Optimistic update (use timestamp IDs for now - Phase 3 will use UUIDs)
const tempId = `temp-${Date.now()}`;
queryClient.setQueryData(featureKeys.lists(), (old: Feature[] = []) =>
[...old, { ...newData, id: tempId }]
);

return { previous, tempId };
},

onError: (err, variables, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(featureKeys.lists(), context.previous);
}
},

onSuccess: (data, variables, context) => {
// Replace optimistic with real data
queryClient.setQueryData(featureKeys.lists(), (old: Feature[] = []) =>
old.map(item => item.id === context?.tempId ? data : item)
);
},
});
}
```

## Testing Query Hooks

Always mock both services and shared patterns:

```typescript
// Mock services
vi.mock("../../services", () => ({
featureService: {
getList: vi.fn(),
getById: vi.fn(),
},
}));

// Mock shared patterns with ALL values
vi.mock("../../../shared/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,
realtime: 3_000,
frequent: 5_000,
normal: 30_000,
rare: 300_000,
static: Infinity,
},
}));
```

## Vertical Slice Architecture

Each feature is self-contained:

```
src/features/projects/
├── components/ # UI components
├── hooks/
│ └── useProjectQueries.ts # Query hooks & keys
├── services/
│ └── projectService.ts # API calls
└── types/
└── index.ts # TypeScript types
```

Sub-features (like tasks under projects) follow the same structure:

```
src/features/projects/tasks/
├── components/
├── hooks/
│ └── useTaskQueries.ts # Own query keys!
├── services/
└── types/
```

## Migration Checklist

When refactoring to these patterns:

- [ ] Create query key factory in `hooks/use{Feature}Queries.ts`
- [ ] Import `DISABLED_QUERY_KEY` and `STALE_TIMES` from shared
- [ ] Replace all hardcoded disabled keys with `DISABLED_QUERY_KEY`
- [ ] Replace all hardcoded stale times with `STALE_TIMES` constants
- [ ] Update all `queryKey` references to use factory
- [ ] Update all `invalidateQueries` to use factory
- [ ] Update all `setQueryData` to use factory
- [ ] Add comprehensive tests for query keys
- [ ] Remove any backward compatibility code

## Common Pitfalls to Avoid

1. **Don't create centralized query keys** - Each feature owns its keys
2. **Don't hardcode values** - Use shared constants
3. **Don't mix concerns** - Tasks shouldn't import projectKeys
4. **Don't skip mocking in tests** - Mock both services and patterns
5. **Don't use inconsistent patterns** - Follow the established conventions

## Future Improvements (Phase 3+)

- Replace timestamp IDs (`temp-${Date.now()}`) with UUIDs
- Add Server-Sent Events for real-time updates
- Consider Zustand for complex client state
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useToast } from "../../ui/hooks/useToast";
import { Button, Input, Label } from "../../ui/primitives";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../ui/primitives/dialog";
import { cn } from "../../ui/primitives/styles";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives/tabs";
import { Tabs, TabsContent } from "../../ui/primitives/tabs";
import { useCrawlUrl, useUploadDocument } from "../hooks";
import type { CrawlRequest, UploadMetadata } from "../types";
import { KnowledgeTypeSelector } from "./KnowledgeTypeSelector";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { format } from "date-fns";
import { motion } from "framer-motion";
import { Briefcase, Clock, Code, ExternalLink, File, FileText, Globe, Terminal } from "lucide-react";
import { useState } from "react";
import { KnowledgeCardProgress } from "../../progress/components/KnowledgeCardProgress";
import type { ActiveOperation } from "../../progress/types";
import { StatPill } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
import { SimpleTooltip } from "../../ui/primitives/tooltip";
import { useDeleteKnowledgeItem, useRefreshKnowledgeItem } from "../hooks";
import { KnowledgeCardProgress } from "../progress/components/KnowledgeCardProgress";
import type { ActiveOperation } from "../progress/types";
import type { KnowledgeItem } from "../types";
import { extractDomain } from "../utils/knowledge-utils";
import { KnowledgeCardActions } from "./KnowledgeCardActions";
Expand Down Expand Up @@ -232,7 +232,7 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
<KnowledgeCardTitle
sourceId={item.source_id}
title={item.title}
description={(item as any).summary}
description={item.metadata?.description}
accentColor={getAccentColorName()}
/>
</div>
Expand Down Expand Up @@ -268,7 +268,7 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
role="none"
className="mt-2"
>
<KnowledgeCardTags sourceId={item.source_id} tags={item.tags || item.metadata?.tags || []} />
<KnowledgeCardTags sourceId={item.source_id} tags={item.metadata?.tags || []} />
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, Loader2 } from "lucide-react";
import type { ActiveOperation } from "../../progress/types";
import { Button } from "../../ui/primitives";
import type { ActiveOperation } from "../progress/types";
import type { KnowledgeItem } from "../types";
import { KnowledgeCard } from "./KnowledgeCard";
import { KnowledgeTable } from "./KnowledgeTable";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,25 @@ describe("useKnowledgeQueries", () => {
expect(knowledgeKeys.all).toEqual(["knowledge"]);
expect(knowledgeKeys.lists()).toEqual(["knowledge", "list"]);
expect(knowledgeKeys.detail("source-123")).toEqual(["knowledge", "detail", "source-123"]);
expect(knowledgeKeys.chunks("source-123", "example.com")).toEqual([
expect(knowledgeKeys.chunks("source-123", { domain: "example.com" })).toEqual([
"knowledge",
"detail",
"source-123",
"chunks",
"example.com",
{ domain: "example.com", limit: undefined, offset: undefined },
]);
expect(knowledgeKeys.codeExamples("source-123")).toEqual([
"knowledge",
"source-123",
"code-examples",
{ limit: undefined, offset: undefined },
]);
expect(knowledgeKeys.codeExamples("source-123")).toEqual(["knowledge", "detail", "source-123", "code-examples"]);
expect(knowledgeKeys.search("test query")).toEqual(["knowledge", "search", "test query"]);
expect(knowledgeKeys.sources()).toEqual(["knowledge", "sources"]);
});

it("should handle filter in list key", () => {
it("should handle filter in summaries key", () => {
const filter = { knowledge_type: "technical" as const, page: 2 };
expect(knowledgeKeys.list(filter)).toEqual(["knowledge", "list", filter]);
expect(knowledgeKeys.summaries(filter)).toEqual(["knowledge", "summaries", filter]);
});
});

Expand Down Expand Up @@ -122,12 +126,22 @@ describe("useKnowledgeQueries", () => {
message: "Item deleted",
});

const wrapper = createWrapper();
const { result } = renderHook(() => useDeleteKnowledgeItem(), { wrapper });
// Create QueryClient instance that will be used by the test
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});

// Pre-populate cache with the same client instance
queryClient.setQueryData(knowledgeKeys.lists(), initialData);

// Pre-populate cache
const queryClient = new QueryClient();
queryClient.setQueryData(knowledgeKeys.list(), initialData);
// Create wrapper with the pre-populated QueryClient
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);

const { result } = renderHook(() => useDeleteKnowledgeItem(), { wrapper });

await result.current.mutateAsync("source-1");

Expand Down
Loading