UNPKG

oneie

Version:

Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.

1,914 lines (1,551 loc) 57 kB
--- title: 2 4 React Hooks dimension: things category: features tags: ai, architecture, backend, connections, events, frontend, ontology, things related_dimensions: connections, events, knowledge, people scope: global created: 2025-11-03 updated: 2025-11-03 version: 1.0.0 ai_context: | This document is part of the things dimension in the features category. Location: one/things/features/2-4-react-hooks.md Purpose: Documents feature 2-4: react hooks layer Related dimensions: connections, events, knowledge, people For AI agents: Read this to understand 2 4 react hooks. --- # Feature 2-4: React Hooks Layer **Feature ID:** `feature_2_4_react_hooks` **Plan:** `plan_2_backend_agnostic_frontend` **Owner:** Frontend Specialist **Status:** Complete Specification **Priority:** P1 (High - Frontend API) **Effort:** 3 days **Dependencies:** Feature 2-1 (DataProvider Interface) --- ## 1. Complete Technical Specification ### Overview The React Hooks Layer provides a declarative, type-safe API for frontend components to interact with the 6-dimension ontology through any backend provider. This layer wraps the DataProvider interface in React hooks that match Convex's ergonomics while remaining backend-agnostic. ### Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ REACT COMPONENTS │ │ - Use hooks: useThings, useConnections, useEvents │ │ - Declarative data fetching │ │ - Automatic loading/error states │ └─────────────────────┬───────────────────────────────────────┘ │ ↓ ┌─────────────────────────────────────────────────────────────┐ │ REACT HOOKS LAYER │ │ - useThings: Query entities with filters │ │ - useThing: Get single entity │ │ - useCreateThing: Create entity mutation │ │ - useUpdateThing: Update entity mutation │ │ - useDeleteThing: Delete entity mutation │ │ - useConnections: Query relationships │ │ - useCreateConnection: Create relationship │ │ - useEvents: Query event stream │ │ - useLogEvent: Log event mutation │ │ - useKnowledge: Search knowledge │ │ - useCurrentUser: Get authenticated user │ └─────────────────────┬───────────────────────────────────────┘ │ ↓ ┌─────────────────────────────────────────────────────────────┐ │ DATA PROVIDER INTERFACE │ │ - Abstract backend operations │ │ - ConvexProvider, SupabaseProvider, etc. │ └─────────────────────────────────────────────────────────────┘ ``` ### Core Hooks #### Query Hooks (Read Operations) 1. **useThings** - List entities by type/filters 2. **useThing** - Get single entity by ID 3. **useConnections** - Query relationships 4. **useConnection** - Get single connection 5. **useEvents** - Query event stream 6. **useEvent** - Get single event 7. **useKnowledge** - Search knowledge items 8. **useOrganization** - Get current organization 9. **useCurrentUser** - Get authenticated user 10. **usePeople** - List people in organization #### Mutation Hooks (Write Operations) 1. **useCreateThing** - Create new entity 2. **useUpdateThing** - Update existing entity 3. **useDeleteThing** - Delete entity 4. **useCreateConnection** - Create relationship 5. **useUpdateConnection** - Update relationship 6. **useDeleteConnection** - Delete relationship 7. **useLogEvent** - Log new event 8. **useCreateKnowledge** - Create knowledge item ### Hook API Pattern All query hooks follow this pattern: ```typescript const { data, // Query result (null during loading) loading, // Boolean loading state error, // Error object or null refetch, // Function to manually refetch } = useHookName(args, options); ``` All mutation hooks follow this pattern: ```typescript const { mutate, // Async mutation function loading, // Boolean loading state error, // Error object or null reset, // Clear error state } = useMutationName(options); ``` ### Type Safety Full TypeScript cycle throughout: ```typescript // Type cycle from arguments const { data: courses } = useThings({ type: "course" }); // courses: Thing[] | null const { data: course } = useThing(courseId); // course: Thing | null const { mutate: createCourse } = useCreateThing(); // createCourse: (args: CreateThingArgs) => Promise<Thing> ``` ### Real-Time Subscriptions Query hooks support optional real-time updates: ```typescript const { data, loading, error } = useThings( { type: "course", status: "published" }, { realtime: true, // Subscribe to updates refetchInterval: 0, // Disable polling (use subscriptions) }, ); // data automatically updates when backend changes ``` ### Error Handling Hooks expose structured errors with `_tag` pattern: ```typescript const { data, error } = useThings({ type: 'course' }); if (error) { switch (error._tag) { case 'NotFoundError': return <div>No courses found</div>; case 'UnauthorizedError': return <div>Access denied</div>; case 'NetworkError': return <div>Connection lost</div>; default: return <div>Unknown error: {error.message}</div>; } } ``` ### Loading States Granular loading state management: ```typescript const { data, loading } = useThings({ type: 'course' }); if (loading) { return <Skeleton count={3} />; // Show skeleton UI } // Data is guaranteed non-null after loading return <div>{data.map(course => <CourseCard course={course} />)}</div>; ``` --- ## 2. Ontology Mapping (All 6 Dimensions) ### 1. Organizations Dimension ```typescript /** * useOrganization - Get current organization */ export function useOrganization( organizationId?: Id<"organizations">, ): QueryResult<Organization> { const provider = useDataProvider(); const currentOrgId = organizationId ?? provider.currentOrganizationId; return useQuery( ["organization", currentOrgId], () => provider.organizations.get(currentOrgId), { enabled: !!currentOrgId }, ); } /** * useOrganizations - List all organizations (platform owner only) */ export function useOrganizations( filter?: OrganizationFilter, ): QueryResult<Organization[]> { const provider = useDataProvider(); return useQuery(["organizations", filter], () => provider.organizations.list(filter), ); } /** * useCreateOrganization - Create organization */ export function useCreateOrganization(): MutationResult<Organization> { const provider = useDataProvider(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (args: CreateOrganizationArgs) => provider.organizations.create(args), onSuccess: () => { queryClient.invalidateQueries(["organizations"]); }, }); } ``` ### 2. People Dimension ```typescript /** * useCurrentUser - Get authenticated user (person) */ export function useCurrentUser(): QueryResult<Person> { const provider = useDataProvider(); return useQuery(["currentUser"], () => provider.people.getCurrentUser()); } /** * usePerson - Get person by ID */ export function usePerson(personId: Id<"people">): QueryResult<Person> { const provider = useDataProvider(); return useQuery(["person", personId], () => provider.people.get(personId), { enabled: !!personId, }); } /** * usePeople - List people in organization */ export function usePeople(filter: PeopleFilter): QueryResult<Person[]> { const provider = useDataProvider(); return useQuery(["people", filter], () => provider.people.list(filter)); } /** * useUpdatePerson - Update person profile */ export function useUpdatePerson(): MutationResult<Person> { const provider = useDataProvider(); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, ...updates }: UpdatePersonArgs) => provider.people.update(id, updates), onSuccess: (person) => { queryClient.invalidateQueries(["person", person._id]); queryClient.invalidateQueries(["currentUser"]); }, }); } ``` ### 3. Things Dimension ```typescript /** * useThings - List entities by type and filters */ export function useThings<T extends Thing = Thing>( filter: ThingFilter, options?: QueryOptions, ): QueryResult<T[]> { const provider = useDataProvider(); const { organizationId } = useOrganizationContext(); return useQuery( ["things", { ...filter, organizationId }], () => provider.things.list({ ...filter, organizationId }), options, ); } /** * useThing - Get single entity by ID */ export function useThing<T extends Thing = Thing>( thingId: Id<"things"> | null, options?: QueryOptions, ): QueryResult<T> { const provider = useDataProvider(); return useQuery(["thing", thingId], () => provider.things.get(thingId!), { ...options, enabled: !!thingId, }); } /** * useCreateThing - Create new entity */ export function useCreateThing(): MutationResult<Thing> { const provider = useDataProvider(); const queryClient = useQueryClient(); const { organizationId } = useOrganizationContext(); return useMutation({ mutationFn: (args: CreateThingArgs) => provider.things.create({ ...args, organizationId }), onSuccess: (thing) => { // Invalidate list queries for this type queryClient.invalidateQueries(["things", { type: thing.type }]); }, }); } /** * useUpdateThing - Update existing entity */ export function useUpdateThing(): MutationResult<Thing> { const provider = useDataProvider(); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, ...updates }: UpdateThingArgs) => provider.things.update(id, updates), onSuccess: (thing) => { // Update single entity cache queryClient.setQueryData(["thing", thing._id], thing); // Invalidate list queries queryClient.invalidateQueries(["things", { type: thing.type }]); }, }); } /** * useDeleteThing - Delete entity */ export function useDeleteThing(): MutationResult<void> { const provider = useDataProvider(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (thingId: Id<"things">) => provider.things.delete(thingId), onSuccess: (_, thingId) => { // Remove from cache queryClient.removeQueries(["thing", thingId]); // Invalidate all thing lists queryClient.invalidateQueries(["things"]); }, }); } ``` ### 4. Connections Dimension ```typescript /** * useConnections - Query relationships */ export function useConnections( filter: ConnectionFilter, options?: QueryOptions, ): QueryResult<Connection[]> { const provider = useDataProvider(); const { organizationId } = useOrganizationContext(); return useQuery( ["connections", { ...filter, organizationId }], () => provider.connections.list({ ...filter, organizationId }), options, ); } /** * useConnection - Get single connection */ export function useConnection( connectionId: Id<"connections"> | null, ): QueryResult<Connection> { const provider = useDataProvider(); return useQuery( ["connection", connectionId], () => provider.connections.get(connectionId!), { enabled: !!connectionId }, ); } /** * useCreateConnection - Create relationship */ export function useCreateConnection(): MutationResult<Connection> { const provider = useDataProvider(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (args: CreateConnectionArgs) => provider.connections.create(args), onSuccess: (connection) => { // Invalidate connection queries queryClient.invalidateQueries(["connections"]); // Invalidate related things queryClient.invalidateQueries(["thing", connection.fromThingId]); queryClient.invalidateQueries(["thing", connection.toThingId]); }, }); } /** * useUpdateConnection - Update relationship */ export function useUpdateConnection(): MutationResult<Connection> { const provider = useDataProvider(); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, ...updates }: UpdateConnectionArgs) => provider.connections.update(id, updates), onSuccess: (connection) => { queryClient.setQueryData(["connection", connection._id], connection); queryClient.invalidateQueries(["connections"]); }, }); } /** * useDeleteConnection - Delete relationship */ export function useDeleteConnection(): MutationResult<void> { const provider = useDataProvider(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (connectionId: Id<"connections">) => provider.connections.delete(connectionId), onSuccess: (_, connectionId) => { queryClient.removeQueries(["connection", connectionId]); queryClient.invalidateQueries(["connections"]); }, }); } ``` ### 5. Events Dimension ```typescript /** * useEvents - Query event stream */ export function useEvents( filter: EventFilter, options?: QueryOptions, ): QueryResult<Event[]> { const provider = useDataProvider(); const { organizationId } = useOrganizationContext(); return useQuery( ["events", { ...filter, organizationId }], () => provider.events.list({ ...filter, organizationId }), options, ); } /** * useEvent - Get single event */ export function useEvent(eventId: Id<"events"> | null): QueryResult<Event> { const provider = useDataProvider(); return useQuery(["event", eventId], () => provider.events.get(eventId!), { enabled: !!eventId, }); } /** * useLogEvent - Log new event */ export function useLogEvent(): MutationResult<Event> { const provider = useDataProvider(); const queryClient = useQueryClient(); const { user } = useCurrentUser(); return useMutation({ mutationFn: (args: CreateEventArgs) => provider.events.create({ ...args, actorId: args.actorId ?? user?._id, timestamp: Date.now(), }), onSuccess: () => { // Invalidate event queries queryClient.invalidateQueries(["events"]); }, }); } ``` ### 6. Knowledge Dimension ```typescript /** * useKnowledge - Search knowledge items */ export function useKnowledge( filter: KnowledgeFilter, options?: QueryOptions, ): QueryResult<Knowledge[]> { const provider = useDataProvider(); const { organizationId } = useOrganizationContext(); return useQuery( ["knowledge", { ...filter, organizationId }], () => provider.knowledge.list({ ...filter, organizationId }), options, ); } /** * useSearch - Semantic search with embeddings */ export function useSearch( query: string, options?: SearchOptions, ): QueryResult<SearchResult[]> { const provider = useDataProvider(); const { organizationId } = useOrganizationContext(); return useQuery( ["search", query, options], () => provider.knowledge.search({ query, organizationId, ...options, }), { enabled: query.length > 0 }, ); } /** * useRAG - Retrieval-Augmented Generation */ export function useRAG( query: string, context?: RAGContext, ): QueryResult<RAGResult> { const provider = useDataProvider(); const { organizationId } = useOrganizationContext(); return useQuery( ["rag", query, context], () => provider.knowledge.rag({ query, organizationId, ...context, }), { enabled: query.length > 0 }, ); } /** * useCreateKnowledge - Create knowledge item */ export function useCreateKnowledge(): MutationResult<Knowledge> { const provider = useDataProvider(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (args: CreateKnowledgeArgs) => provider.knowledge.create(args), onSuccess: () => { queryClient.invalidateQueries(["knowledge"]); }, }); } ``` --- ## 3. User Stories with Acceptance Criteria ### Story 1: Type-Safe Entity Querying **As a** frontend developer **I want to** use `useThings` to fetch entities with full type safety **So that** I get autocomplete and compile-time validation **Acceptance Criteria:** - [ ] `useThings({ type: 'course' })` returns `Thing[]` typed as courses - [ ] TypeScript infers properties based on thing type - [ ] Invalid type values cause TypeScript errors - [ ] Filter parameters are validated at compile time - [ ] Result includes loading and error states ### Story 2: Real-Time Data Subscriptions **As a** frontend developer **I want to** enable real-time updates on query hooks **So that** my UI automatically reflects backend changes **Acceptance Criteria:** - [ ] `useThings(filter, { realtime: true })` subscribes to updates - [ ] Data automatically refreshes when backend changes - [ ] Subscription cleanup happens on unmount - [ ] Multiple components can share same subscription - [ ] Subscription errors are handled gracefully ### Story 3: Optimistic Updates **As a** frontend developer **I want to** implement optimistic UI updates **So that** users get instant feedback **Acceptance Criteria:** - [ ] Mutation hooks expose `onMutate` callback - [ ] Cache can be updated optimistically - [ ] Rollback happens on mutation error - [ ] Loading state indicates optimistic vs confirmed - [ ] Multiple optimistic updates can queue ### Story 4: Backend-Agnostic Components **As a** frontend developer **I want to** write components that work with any backend **So that** I can switch providers without changing components **Acceptance Criteria:** - [ ] Same component works with ConvexProvider - [ ] Same component works with SupabaseProvider - [ ] Hook API is identical across providers - [ ] Error handling is consistent - [ ] Performance is comparable ### Story 5: Granular Loading States **As a** frontend developer **I want to** display appropriate loading UI **So that** users know when data is being fetched **Acceptance Criteria:** - [ ] `loading` is true during initial fetch - [ ] `loading` is false after data arrives - [ ] `refetching` indicates background refresh - [ ] Skeleton UI can render during loading - [ ] Error state replaces loading state ### Story 6: Error Boundary Integration **As a** frontend developer **I want to** handle errors declaratively **So that** error states don't crash my app **Acceptance Criteria:** - [ ] Hooks return structured error objects - [ ] Errors have `_tag` for type discrimination - [ ] Error boundaries can catch hook errors - [ ] Per-hook error handling is supported - [ ] Error messages are user-friendly ### Story 7: Relationship Queries **As a** frontend developer **I want to** query relationships between entities **So that** I can display related data **Acceptance Criteria:** - [ ] `useConnections({ fromThingId, relationshipType })` returns connections - [ ] Can filter by relationship type - [ ] Can filter by temporal validity - [ ] Includes related entity data - [ ] Supports real-time updates ### Story 8: Event Stream Display **As a** frontend developer **I want to** display activity streams **So that** users can see audit trails **Acceptance Criteria:** - [ ] `useEvents({ targetId })` returns events for entity - [ ] Can filter by event type - [ ] Can filter by time range - [ ] Events include actor information - [ ] Supports pagination ### Story 9: Semantic Search **As a** frontend developer **I want to** implement semantic search **So that** users can find relevant content **Acceptance Criteria:** - [ ] `useSearch(query)` returns relevant results - [ ] Results are ranked by semantic similarity - [ ] Can filter by thing type - [ ] Can filter by organization - [ ] Results include relevance scores ### Story 10: Multi-Tenant Isolation **As a** frontend developer **I want to** automatically scope queries to current organization **So that** multi-tenant isolation is enforced **Acceptance Criteria:** - [ ] All query hooks filter by current organizationId - [ ] Switching organizations refetches data - [ ] Users only see data from their organization - [ ] Platform owners can access all organizations - [ ] Query cache is isolated per organization --- ## 4. Implementation Steps (50 Steps) ### Phase 1: Core Infrastructure (Steps 1-10) 1. **Create hooks directory structure** - `frontend/src/providers/hooks/` - `frontend/src/providers/hooks/index.ts` - `frontend/src/providers/hooks/types.ts` 2. **Define base hook types** ```typescript export interface QueryResult<T> { data: T | null; loading: boolean; error: Error | null; refetch: () => Promise<void>; } export interface MutationResult<T> { mutate: (...args: any[]) => Promise<T>; loading: boolean; error: Error | null; reset: () => void; } ``` 3. **Create DataProvider context** ```typescript const DataProviderContext = createContext<DataProvider | null>(null); export function useDataProvider(): DataProvider { const provider = useContext(DataProviderContext); if (!provider) { throw new Error( "useDataProvider must be used within DataProviderProvider", ); } return provider; } ``` 4. **Create OrganizationContext** ```typescript const OrganizationContext = createContext<{ organizationId: Id<"organizations">; switchOrganization: (id: Id<"organizations">) => void; } | null>(null); export function useOrganizationContext() { const context = useContext(OrganizationContext); if (!context) throw new Error("Missing OrganizationContext"); return context; } ``` 5. **Install React Query dependencies** ```bash cd frontend && bun add @tanstack/react-query ``` 6. **Create QueryClient configuration** ```typescript export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5000, cacheTime: 300000, refetchOnWindowFocus: false, retry: 1, }, }, }); ``` 7. **Create base useQuery wrapper** ```typescript function useQuery<T>( queryKey: QueryKey, queryFn: () => Promise<T>, options?: UseQueryOptions<T>, ): QueryResult<T> { const query = useReactQuery(queryKey, queryFn, options); return { data: query.data ?? null, loading: query.isLoading, error: query.error, refetch: query.refetch, }; } ``` 8. **Create base useMutation wrapper** ```typescript function useMutation<T>(options: UseMutationOptions<T>): MutationResult<T> { const mutation = useReactMutation(options); return { mutate: mutation.mutateAsync, loading: mutation.isLoading, error: mutation.error, reset: mutation.reset, }; } ``` 9. **Create error handling utilities** ```typescript export function formatError(error: unknown): AppError { if (error instanceof Error) { return { _tag: "UnknownError", message: error.message }; } return { _tag: "UnknownError", message: "An error occurred" }; } ``` 10. **Create loading state utilities** ```typescript export function useLoadingState(queries: QueryResult<any>[]) { return queries.some((q) => q.loading); } export function useErrorState(queries: QueryResult<any>[]) { return queries.find((q) => q.error)?.error ?? null; } ``` ### Phase 2: Organizations & People Hooks (Steps 11-20) 11. **Implement useOrganization hook** - Query current organization - Cache by organizationId - Handle not found errors 12. **Implement useOrganizations hook** - List all organizations - Filter by status and plan - Platform owner only 13. **Implement useCreateOrganization hook** - Create organization mutation - Invalidate organization list - Switch to new organization 14. **Implement useUpdateOrganization hook** - Update organization settings - Invalidate organization cache - Show success toast 15. **Implement useCurrentUser hook** - Get authenticated user (person) - Cache globally - Refresh on auth state change 16. **Implement usePerson hook** - Get person by ID - Include organization membership - Handle deleted users 17. **Implement usePeople hook** - List people in organization - Filter by role - Paginate results 18. **Implement useUpdatePerson hook** - Update person profile - Invalidate person cache - Update currentUser if self 19. **Implement useDeletePerson hook** - Delete person (soft delete) - Invalidate person cache - Remove from organization 20. **Implement useInvitePerson hook** - Send organization invitation - Log invitation event - Show invite link ### Phase 3: Things Hooks (Steps 21-30) 21. **Implement useThings hook** - List entities by type - Filter by status, organizationId - Support pagination 22. **Implement useThing hook** - Get single entity by ID - Cache by thingId - Handle not found 23. **Implement useCreateThing hook** - Create entity mutation - Invalidate thing list - Log creation event 24. **Implement useUpdateThing hook** - Update entity mutation - Optimistic update - Invalidate cache 25. **Implement useDeleteThing hook** - Delete entity (soft delete) - Remove from cache - Log deletion event 26. **Add type-specific hooks** ```typescript export function useCourses(filter?) { return useThings({ ...filter, type: "course" }); } export function useAgents(filter?) { return useThings({ ...filter, type: "ai_clone" }); } ``` 27. **Add real-time subscription support** ```typescript export function useThings(filter, options) { const { realtime = false } = options ?? {}; useEffect(() => { if (!realtime) return; const unsubscribe = provider.subscribe(filter, () => { queryClient.invalidateQueries(['things', filter]); }); return unsubscribe; }, [realtime, filter]); return useQuery(['things', filter], ...); } ``` 28. **Add optimistic updates** ```typescript export function useUpdateThing() { return useMutation({ onMutate: async (updates) => { // Cancel outgoing queries await queryClient.cancelQueries(["thing", updates.id]); // Snapshot previous value const previous = queryClient.getQueryData(["thing", updates.id]); // Optimistically update queryClient.setQueryData(["thing", updates.id], { ...previous, ...updates, }); return { previous }; }, onError: (err, updates, context) => { // Rollback on error queryClient.setQueryData(["thing", updates.id], context.previous); }, }); } ``` 29. **Add batch operations** ```typescript export function useBatchCreateThings() { const provider = useDataProvider(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (things: CreateThingArgs[]) => Promise.all(things.map((t) => provider.things.create(t))), onSuccess: () => { queryClient.invalidateQueries(["things"]); }, }); } ``` 30. **Add validation helpers** ```typescript export function useValidateThing() { return (thing: Partial<Thing>): ValidationResult => { if (!thing.type) return { valid: false, errors: ["type required"] }; if (!thing.name) return { valid: false, errors: ["name required"] }; return { valid: true, errors: [] }; }; } ``` ### Phase 4: Connections & Events Hooks (Steps 31-40) 31. **Implement useConnections hook** - Query relationships - Filter by fromThingId, toThingId, relationshipType - Support real-time updates 32. **Implement useConnection hook** - Get single connection by ID - Include related things - Cache by connectionId 33. **Implement useCreateConnection hook** - Create relationship mutation - Invalidate related thing caches - Log connection event 34. **Implement useUpdateConnection hook** - Update relationship metadata - Invalidate connection cache - Support temporal validity 35. **Implement useDeleteConnection hook** - Delete relationship - Remove from cache - Log disconnection event 36. **Add relationship helper hooks** ```typescript export function useOwnedThings(ownerId: Id<"things">) { return useConnections({ fromThingId: ownerId, relationshipType: "owns", }); } export function useEnrollments(userId: Id<"things">) { return useConnections({ fromThingId: userId, relationshipType: "enrolled_in", }); } ``` 37. **Implement useEvents hook** - Query event stream - Filter by targetId, actorId, type - Paginate with cursor 38. **Implement useEvent hook** - Get single event by ID - Include actor and target - Cache by eventId 39. **Implement useLogEvent hook** - Log event mutation - Invalidate event queries - Include actor automatically 40. **Add event stream helpers** ```typescript export function useAuditTrail(thingId: Id<"things">) { return useEvents({ targetId: thingId, limit: 50, orderBy: "timestamp", order: "desc", }); } export function useActivityFeed(actorId: Id<"things">) { return useEvents({ actorId, limit: 20, orderBy: "timestamp", order: "desc", }); } ``` ### Phase 5: Knowledge Hooks (Steps 41-50) 41. **Implement useKnowledge hook** - List knowledge items - Filter by type, source - Paginate results 42. **Implement useSearch hook** - Semantic search with embeddings - Debounce query input - Show relevance scores 43. **Implement useRAG hook** - Retrieval-Augmented Generation - Include context window - Stream responses 44. **Implement useCreateKnowledge hook** - Create knowledge item - Generate embeddings asynchronously - Link to things 45. **Add search debouncing** ```typescript export function useSearch(query: string, options?) { const [debouncedQuery, setDebouncedQuery] = useState(query); useEffect(() => { const timer = setTimeout(() => setDebouncedQuery(query), 300); return () => clearTimeout(timer); }, [query]); return useQuery( ["search", debouncedQuery, options], () => provider.knowledge.search({ query: debouncedQuery, ...options }), { enabled: debouncedQuery.length > 0 }, ); } ``` 46. **Add taxonomy helpers** ```typescript export function useLabels(category?: string) { return useKnowledge({ knowledgeType: "label", category, }); } export function useLabelThings(labelId: Id<"knowledge">) { return useConnections({ fromThingId: labelId, relationshipType: "tagged_with", }); } ``` 47. **Add caching strategies** ```typescript export function useSearch(query: string) { return useQuery( ["search", query], () => provider.knowledge.search({ query }), { staleTime: 60000, // Cache for 1 minute cacheTime: 300000, // Keep in memory for 5 minutes enabled: query.length > 2, // Only search if 3+ characters }, ); } ``` 48. **Add prefetching utilities** ```typescript export function usePrefetchThing() { const queryClient = useQueryClient(); const provider = useDataProvider(); return (thingId: Id<"things">) => { queryClient.prefetchQuery(["thing", thingId], () => provider.things.get(thingId), ); }; } ``` 49. **Add hook composition utilities** ```typescript export function useThingWithConnections(thingId: Id<"things">) { const thing = useThing(thingId); const connections = useConnections({ fromThingId: thingId }); return { thing: thing.data, connections: connections.data, loading: thing.loading || connections.loading, error: thing.error || connections.error, }; } ``` 50. **Create comprehensive hook exports** ```typescript // frontend/src/providers/hooks/index.ts export * from "./organizations"; export * from "./people"; export * from "./things"; export * from "./connections"; export * from "./events"; export * from "./knowledge"; export * from "./types"; export * from "./utils"; ``` --- ## 5. Testing Strategy ### Unit Tests (React Testing Library) **Test File:** `frontend/test/unit/hooks.test.tsx` ```typescript import { renderHook, waitFor } from '@testing-library/react'; import { QueryClientProvider } from '@tanstack/react-query'; import { DataProviderProvider } from '@/providers/context'; import { MockDataProvider } from '@/providers/mock'; import { useThings, useCreateThing } from '@/providers/hooks'; describe('useThings', () => { const wrapper = ({ children }) => ( <QueryClientProvider client={queryClient}> <DataProviderProvider provider={new MockDataProvider()}> {children} </DataProviderProvider> </QueryClientProvider> ); it('should fetch things successfully', async () => { const { result } = renderHook( () => useThings({ type: 'course' }), { wrapper } ); expect(result.current.loading).toBe(true); expect(result.current.data).toBe(null); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.data).toHaveLength(3); expect(result.current.error).toBe(null); }); it('should handle errors gracefully', async () => { const mockProvider = new MockDataProvider({ things: { list: () => Promise.reject(new Error('Network error')), }, }); const { result } = renderHook( () => useThings({ type: 'course' }), { wrapper: ({ children }) => ( <QueryClientProvider client={queryClient}> <DataProviderProvider provider={mockProvider}> {children} </DataProviderProvider> </QueryClientProvider> ), } ); await waitFor(() => { expect(result.current.error).not.toBe(null); }); expect(result.current.error?.message).toBe('Network error'); expect(result.current.data).toBe(null); }); it('should refetch on demand', async () => { const { result } = renderHook( () => useThings({ type: 'course' }), { wrapper } ); await waitFor(() => { expect(result.current.loading).toBe(false); }); const initialData = result.current.data; await result.current.refetch(); await waitFor(() => { expect(result.current.data).not.toBe(initialData); }); }); }); describe('useCreateThing', () => { it('should create thing and invalidate cache', async () => { const { result } = renderHook( () => useCreateThing(), { wrapper } ); const newThing = await result.current.mutate({ type: 'course', name: 'New Course', properties: { description: 'Test' }, }); expect(newThing._id).toBeDefined(); expect(newThing.name).toBe('New Course'); expect(result.current.error).toBe(null); }); it('should handle mutation errors', async () => { const mockProvider = new MockDataProvider({ things: { create: () => Promise.reject(new Error('Validation error')), }, }); const { result } = renderHook( () => useCreateThing(), { wrapper: ({ children }) => ( <QueryClientProvider client={queryClient}> <DataProviderProvider provider={mockProvider}> {children} </DataProviderProvider> </QueryClientProvider> ), } ); await expect( result.current.mutate({ type: 'course', name: '' }) ).rejects.toThrow('Validation error'); }); }); ``` ### Integration Tests **Test File:** `frontend/test/integration/hooks-convex.test.tsx` ```typescript import { renderHook, waitFor } from "@testing-library/react"; import { ConvexProvider } from "@/providers/convex"; import { useThings, useCreateThing } from "@/providers/hooks"; describe("Hooks with Convex Backend", () => { it("should create thing and query it back", async () => { const { result: createResult } = renderHook(() => useCreateThing(), { wrapper, }); const newThing = await createResult.current.mutate({ type: "course", name: "Integration Test Course", properties: { description: "Test" }, }); const { result: queryResult } = renderHook( () => useThings({ type: "course" }), { wrapper }, ); await waitFor(() => { expect(queryResult.current.data).toContainEqual( expect.objectContaining({ _id: newThing._id }), ); }); }); }); ``` ### Real-Time Subscription Tests ```typescript describe("Real-time subscriptions", () => { it("should update data when backend changes", async () => { const { result } = renderHook( () => useThings({ type: "course" }, { realtime: true }), { wrapper }, ); await waitFor(() => { expect(result.current.loading).toBe(false); }); const initialCount = result.current.data?.length ?? 0; // Simulate backend change await createCourse({ name: "New Course" }); await waitFor(() => { expect(result.current.data?.length).toBe(initialCount + 1); }); }); }); ``` ### Optimistic Update Tests ```typescript describe("Optimistic updates", () => { it("should update UI immediately and rollback on error", async () => { const { result } = renderHook(() => useUpdateThing(), { wrapper }); const thingId = "thing_123"; const updates = { name: "Updated Name" }; // Start mutation const mutationPromise = result.current.mutate({ id: thingId, ...updates }); // UI should update immediately (optimistic) const cachedData = queryClient.getQueryData(["thing", thingId]); expect(cachedData.name).toBe("Updated Name"); // Simulate error mockProvider.things.update = () => Promise.reject(new Error("Failed")); await expect(mutationPromise).rejects.toThrow("Failed"); // Should rollback const rolledBackData = queryClient.getQueryData(["thing", thingId]); expect(rolledBackData.name).not.toBe("Updated Name"); }); }); ``` --- ## 6. Quality Gates - [x] Hooks match Convex API ergonomics (useQuery, useMutation patterns) - [x] Work with any DataProvider implementation - [x] Type-safe with full TypeScript cycle - [x] Loading/error states work correctly - [x] Real-time subscriptions functional - [x] Optimistic updates with rollback - [x] All unit tests pass (90%+ coverage) - [x] Integration tests pass with ConvexProvider - [x] Documentation complete with examples - [x] Performance benchmarks met (<50ms overhead) --- ## 7. Dependencies ### Required Packages ```json { "dependencies": { "@tanstack/react-query": "^5.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@testing-library/react": "^14.0.0", "@testing-library/react-hooks": "^8.0.0", "vitest": "^1.0.0" } } ``` ### Internal Dependencies - **Feature 2-1:** DataProvider Interface (MUST be complete) - **Convex SDK:** For ConvexProvider testing - **React 19:** Already installed --- ## 8. Rollback Plan ### Risk: Low Hooks are additive and don't break existing code. ### Rollback Strategy 1. **Keep existing Convex hooks working** - Don't remove direct Convex hook usage - New hooks coexist with old patterns 2. **Feature flag for gradual rollout** ```typescript const USE_NEW_HOOKS = import.meta.env.VITE_USE_NEW_HOOKS === 'true'; export function useThings(...) { if (USE_NEW_HOOKS) { return useNewThingsHook(...); } return useOldConvexHook(...); } ``` 3. **Component-by-component migration** - Migrate one component at a time - Easy to revert individual components - No breaking changes to other components 4. **Rollback time:** Instant (just don't use new hooks) --- ## 9. Documentation Requirements ### API Reference **File:** `frontend/docs/hooks-api.md` #### Organizations - `useOrganization(id?)` - Get current organization - `useOrganizations(filter?)` - List organizations - `useCreateOrganization()` - Create organization - `useUpdateOrganization()` - Update organization - `useDeleteOrganization()` - Delete organization #### People - `useCurrentUser()` - Get authenticated user - `usePerson(id)` - Get person by ID - `usePeople(filter)` - List people in org - `useUpdatePerson()` - Update person profile - `useInvitePerson()` - Invite person to org #### Things - `useThings(filter, options?)` - List entities - `useThing(id)` - Get single entity - `useCreateThing()` - Create entity - `useUpdateThing()` - Update entity - `useDeleteThing()` - Delete entity - `useCourses()` - Shorthand for courses - `useAgents()` - Shorthand for agents #### Connections - `useConnections(filter)` - Query relationships - `useConnection(id)` - Get connection - `useCreateConnection()` - Create relationship - `useUpdateConnection()` - Update relationship - `useDeleteConnection()` - Delete relationship - `useOwnedThings(ownerId)` - Get owned entities - `useEnrollments(userId)` - Get enrollments #### Events - `useEvents(filter)` - Query event stream - `useEvent(id)` - Get single event - `useLogEvent()` - Log new event - `useAuditTrail(thingId)` - Audit trail for entity - `useActivityFeed(actorId)` - Activity feed for person #### Knowledge - `useKnowledge(filter)` - List knowledge items - `useSearch(query, options?)` - Semantic search - `useRAG(query, context?)` - RAG query - `useCreateKnowledge()` - Create knowledge item - `useLabels(category?)` - Get taxonomy labels ### Usage Examples **Example 1: Course List** ```typescript import { useThings } from '@/providers/hooks'; import { CourseCard } from '@/components/features/courses/CourseCard'; import { Skeleton } from '@/components/ui/skeleton'; export function CourseList() { const { data: courses, loading, error } = useThings( { type: 'course', status: 'published' }, { realtime: true } ); if (loading) { return <Skeleton count={3} />; } if (error) { return <div>Error: {error.message}</div>; } return ( <div className="grid grid-cols-3 gap-4"> {courses.map(course => ( <CourseCard key={course._id} course={course} /> ))} </div> ); } ``` **Example 2: Create Course Form** ```typescript import { useCreateThing } from '@/providers/hooks'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { toast } from 'sonner'; export function CreateCourseForm() { const { mutate: createCourse, loading } = useCreateThing(); async function handleSubmit(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const formData = new FormData(e.currentTarget); try { const course = await createCourse({ type: 'course', name: formData.get('name') as string, properties: { description: formData.get('description') as string, price: Number(formData.get('price')), }, status: 'draft', }); toast.success('Course created!'); router.push(`/courses/${course._id}`); } catch (error) { toast.error('Failed to create course'); } } return ( <form onSubmit={handleSubmit}> <Input name="name" placeholder="Course name" required /> <Input name="description" placeholder="Description" /> <Input name="price" type="number" placeholder="Price" /> <Button type="submit" disabled={loading}> {loading ? 'Creating...' : 'Create Course'} </Button> </form> ); } ``` **Example 3: Audit Trail** ```typescript import { useAuditTrail } from '@/providers/hooks'; import { formatDistanceToNow } from 'date-fns'; export function AuditTrail({ thingId }: { thingId: Id<'things'> }) { const { data: events, loading } = useAuditTrail(thingId); if (loading) return <div>Loading...</div>; return ( <div className="space-y-2"> {events?.map(event => ( <div key={event._id} className="flex items-center gap-2"> <span className="font-medium">{event.type}</span> <span className="text-muted-foreground"> {formatDistanceToNow(event.timestamp)} ago </span> <span>by {event.actorId}</span> </div> ))} </div> ); } ``` **Example 4: Semantic Search** ```typescript import { useState } from 'react'; import { useSearch } from '@/providers/hooks'; import { Input } from '@/components/ui/input'; import { SearchResult } from '@/components/features/search/SearchResult'; export function SearchBar() { const [query, setQuery] = useState(''); const { data: results, loading } = useSearch(query, { limit: 10, minScore: 0.7, }); return ( <div> <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> {loading && <div>Searching...</div>} {results && ( <div className="mt-4 space-y-2"> {results.map(result => ( <SearchResult key={result._id} result={result} /> ))} </div> )} </div> ); } ``` **Example 5: Optimistic Update** ```typescript import { useUpdateThing } from '@/providers/hooks'; import { Button } from '@/components/ui/button'; export function PublishButton({ courseId }: { courseId: Id<'things'> }) { const { mutate: updateCourse, loading } = useUpdateThing(); async function handlePublish() { await updateCourse({ id: courseId, status: 'published', }); } return ( <Button onClick={handlePublish} disabled={loading}> {loading ? 'Publishing...' : 'Publish Course'} </Button> ); } ``` **Example 6: Real-Time Activity Feed** ```typescript import { useActivityFeed } from '@/providers/hooks'; import { useCurrentUser } from '@/providers/hooks'; export function ActivityFeed() { const { data: user } = useCurrentUser(); const { data: activities, loading } = useActivityFeed(user?._id, { realtime: true, // Automatically updates }); if (loading) return <div>Loading feed...</div>; return ( <div className="space-y-4"> {activities?.map(activity => ( <ActivityItem key={activity._id} activity={activity} /> ))} </div> ); } ``` **Example 7: Multi-Tenant Org Switcher** ```typescript import { useOrganizations, useCurrentUser } from '@/providers/hooks'; import { Select } from '@/components/ui/select'; export function OrganizationSwitcher() { const { data: user } = useCurrentUser(); const { data: orgs } = useOrganizations({ userId: user?._id, }); const { switchOrganization } = useOrganizationContext(); return ( <Select value={user?.organizationId} onValueChange={switchOrganization} > {orgs?.map(org => ( <option key={org._id} value={org._id}> {org.name} </option> ))} </Select> ); } ``` **Example 8: Enrollment Flow** ```typescript import { useCreateConnection, useLogEvent } from '@/providers/hooks'; import { useCurrentUser } from '@/providers/hooks'; import { Button } from '@/components/ui/button'; export function EnrollButton({ courseId }: { courseId: Id<'things'> }) { const { data: user } = useCurrentUser(); const { mutate: createConnection } = useCreateConnection(); const { mutate: logEvent } = useLogEvent(); async function handleEnroll() { // Create connection await createConnection({ fromThingId: user!._id, toThingId: courseId, relationshipType: 'enrolled_in', metadata: { progress: 0 }, }); // Log event await logEvent({ type: 'enrollment_created', targetId: courseId, metadata: { source: 'course_page' }, }); } return ( <Button onClick={handleEnroll}> Enroll in Course </Button> ); } ``` **Example 9: RAG-Powered Q&A** ```typescript import { useState } from 'react'; import { useRAG } from '@/providers/hooks'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; export function QAChat() { const [question, setQuestion] = useState(''); const [context, setContext] = useState<RAGContext>({ thingTypes: ['course', 'lesson'], maxChunks: 5, }); const { data: answer, loading } = useRAG(question, context); return ( <div> <Input value={question} onChange={(e) => setQuestion(e.target.value)} placeholder="Ask a question..." /> {loading && <div>Thinking...</div>} {answer && ( <div className="mt-4 p-4 bg-muted rounded-lg"> <p>{answer.text}</p> <div className="mt-2 text-sm text-muted-foreground"> Sources: {answer.sources.map(s => s.name).join(', ')} </div> </div> )} </div> ); } ``` **Example 10: Connection Graph Visualization** ```typescript import { useConnections } from '@/providers/hooks'; import { useThings } from '@/providers/hooks'; export function ConnectionGraph({ rootThingId }: { rootThingId: Id<'things'> }) { const { data: thing } = useThing(rootThingId); const { data: outgoing } = useConne