This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

graph view backend

+782
+218
.agent/logs/20260304_semble_graph_view.md
··· 1 + # Sigma graph library 2 + 3 + ## Data Schema / Graph Data Model 4 + 5 + ### Node Types 6 + 7 + The graph consists of four primary node types: 8 + 9 + 1. **Users** - User DIDs (Decentralized Identifiers) 10 + - Source: `users.id` 11 + - Display: `users.handle` or DID 12 + 13 + 2. **URLs** - Web resources saved as URL cards 14 + - Source: `cards.url` where `cards.type = 'URL'` 15 + - Metadata: `cards.urlType`, `cards.contentData` 16 + 17 + 3. **Collections** - Curated groups of cards 18 + - Source: `collections.id` 19 + - Display: `collections.name` 20 + - Metadata: `collections.description`, `collections.accessType` 21 + 22 + 4. **Note Cards** - User annotations and notes 23 + - Source: `cards.id` where `cards.type = 'NOTE'` 24 + - Metadata: `cards.contentData` 25 + 26 + ### Edge Types 27 + 28 + The graph relationships are derived from the following sources: 29 + 30 + #### 1. Follow Relationships 31 + 32 + **User → User** (social follows) 33 + 34 + ```sql 35 + SELECT followerId, targetId 36 + FROM follows 37 + WHERE targetType = 'user' 38 + ``` 39 + 40 + **User → Collection** (collection follows) 41 + 42 + ```sql 43 + SELECT followerId, targetId 44 + FROM follows 45 + WHERE targetType = 'collection' 46 + ``` 47 + 48 + #### 2. Authorship (User → URL) 49 + 50 + Inferred from URL card creation: 51 + 52 + ```sql 53 + SELECT authorId as userId, url 54 + FROM cards 55 + WHERE type = 'URL' AND url IS NOT NULL 56 + ``` 57 + 58 + #### 3. Note Connections (Note → URL) 59 + 60 + Inferred from note cards referencing parent URL cards: 61 + 62 + ```sql 63 + SELECT n.id as noteCardId, p.url 64 + FROM cards n 65 + JOIN cards p ON n.parentCardId = p.id 66 + WHERE n.type = 'NOTE' AND p.type = 'URL' 67 + ``` 68 + 69 + #### 4. Collection Memberships (Collection ↔ URL) 70 + 71 + Inferred from collection-card relationships: 72 + 73 + ```sql 74 + SELECT cc.collectionId, c.url 75 + FROM collection_cards cc 76 + JOIN cards c ON cc.cardId = c.id 77 + WHERE c.type = 'URL' AND c.url IS NOT NULL 78 + ``` 79 + 80 + Alternative (include metadata): 81 + 82 + ```sql 83 + SELECT 84 + cc.collectionId, 85 + c.url, 86 + cc.addedBy, 87 + cc.addedAt 88 + FROM collection_cards cc 89 + JOIN cards c ON cc.cardId = c.id 90 + WHERE c.url IS NOT NULL 91 + ``` 92 + 93 + ### Additional Relationship Data 94 + 95 + The `connections` table provides explicit curator-defined relationships between nodes: 96 + 97 + ```sql 98 + SELECT 99 + curatorId, 100 + sourceType, -- 'URL' or 'CARD' 101 + sourceValue, -- URL string or Card UUID 102 + targetType, -- 'URL' or 'CARD' 103 + targetValue, -- URL string or Card UUID 104 + connectionType, -- SUPPORTS, OPPOSES, etc. 105 + note 106 + FROM connections 107 + ``` 108 + 109 + These can be used to create typed/weighted edges with semantic meaning (e.g., "supports", "opposes"). 110 + 111 + --- 112 + 113 + ## **Sigma.js + Next.js Setup** 114 + 115 + ### **1. Install** 116 + 117 + ```bash 118 + npm install sigma graphology @react-sigma/core @react-sigma/layout-forceatlas2 119 + ``` 120 + 121 + ### **2. Dynamic Import Component** 122 + 123 + Create `components/Graph.tsx` (no `'use client'` needed—dynamic import handles it): 124 + 125 + ```typescript 126 + 'use client'; 127 + import { SigmaContainer, useSigma } from '@react-sigma/core'; 128 + import { useEffect, useState } from 'react'; 129 + import Graph from 'graphology'; 130 + 131 + export default function GraphView() { 132 + const [graph, setGraph] = useState<Graph | null>(null); 133 + 134 + useEffect(() => { 135 + const g = new Graph(); 136 + g.addNode('a', { x: 0, y: 0, size: 10, label: 'Node A' }); 137 + g.addNode('b', { x: 1, y: 1, size: 15, label: 'Node B' }); 138 + g.addEdge('a', 'b'); 139 + setGraph(g); 140 + }, []); 141 + 142 + if (!graph) return null; 143 + 144 + return ( 145 + <SigmaContainer style={{ height: '600px', background: '#1e1e1e' }} graph={graph}> 146 + <GraphEvents /> 147 + </SigmaContainer> 148 + ); 149 + } 150 + ``` 151 + 152 + ### **3. Events & Camera Controls** 153 + 154 + ```typescript 155 + function GraphEvents() { 156 + const sigma = useSigma(); 157 + 158 + const zoomToNode = (nodeId: string) => { 159 + const node = sigma.getGraph().getNodeAttributes(nodeId); 160 + sigma.getCamera().animate( 161 + { x: node.x, y: node.y, ratio: 0.3 }, 162 + { duration: 800 } 163 + ); 164 + }; 165 + 166 + return ( 167 + <Controls zoomToNode={zoomToNode} /> 168 + ); 169 + } 170 + ``` 171 + 172 + ### **4. Page Integration** 173 + 174 + ```typescript 175 + import dynamic from 'next/dynamic'; 176 + 177 + const Graph = dynamic(() => import('@/components/Graph'), { ssr: false }); 178 + 179 + export default function Page() { 180 + return <Graph />; 181 + } 182 + ``` 183 + 184 + ### **5. Force Layout** 185 + 186 + Add physics with `useLayoutForceAtlas2()` hook from `@react-sigma/layout-forceatlas2` [^3]: 187 + 188 + ```typescript 189 + import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'; 190 + 191 + function GraphWithPhysics({ graph }: { graph: Graph }) { 192 + const { start, stop } = useLayoutForceAtlas2({ 193 + settings: { gravity: 0.5 }, 194 + }); 195 + 196 + useEffect(() => { 197 + start(); 198 + return () => stop(); 199 + }, []); 200 + return null; 201 + } 202 + ``` 203 + 204 + **Key notes:** Always use `ssr: false` [^2]. Sigma uses WebGL [^1]—canvas rendering, not DOM elements. 205 + 206 + [^1]: [Introduction | sigma.js](https://www.sigmajs.org/docs/) (27%) 207 + 208 + [^2]: [Frequently Asked Questions | React Sigma](https://sim51.github.io/react-sigma/docs/faq/) (21%) 209 + 210 + [^3]: [react-sigma / sigmaJS example using a force layout?](https://stackoverflow.com/questions/78805061/react-sigma-sigmajs-example-using-a-force-layout) (20%) 211 + 212 + [^4]: [Introduction | React Sigma - GitHub Pages](https://sim51.github.io/react-sigma/docs/start-introduction/) (18%) 213 + 214 + [^5]: [SEO: Dynamic Imports for Components](https://nextjs.org/learn/seo/dynamic-import-components) (6%) 215 + 216 + [^6]: [SEO: Dynamic Imports | Next.js](https://nextjs.org/learn/seo/dynamic-imports) (5%) 217 + 218 + [^7]: [React Sigma | React Sigma - GitHub Pages](https://sim51.github.io/react-sigma/) (3%)
+57
src/modules/cards/application/useCases/queries/GetGraphDataUseCase.ts
··· 1 + import { Result, ok, err } from '../../../../../shared/core/Result'; 2 + import { UseCase } from '../../../../../shared/core/UseCase'; 3 + import { IGraphQueryRepository } from '../../../domain/IGraphQueryRepository'; 4 + 5 + export interface GetGraphDataQuery { 6 + // No parameters needed for global graph 7 + } 8 + 9 + export interface GraphNode { 10 + id: string; 11 + type: 'USER' | 'URL' | 'COLLECTION' | 'NOTE'; 12 + label: string; 13 + metadata: Record<string, any>; 14 + } 15 + 16 + export interface GraphEdge { 17 + id: string; 18 + source: string; 19 + target: string; 20 + type: 21 + | 'USER_FOLLOWS_USER' 22 + | 'USER_FOLLOWS_COLLECTION' 23 + | 'USER_AUTHORED_URL' 24 + | 'NOTE_REFERENCES_URL' 25 + | 'COLLECTION_CONTAINS_URL' 26 + | 'URL_CONNECTS_URL'; 27 + metadata?: Record<string, any>; 28 + } 29 + 30 + export interface GetGraphDataResult { 31 + nodes: GraphNode[]; 32 + edges: GraphEdge[]; 33 + } 34 + 35 + export class GetGraphDataUseCase 36 + implements UseCase<GetGraphDataQuery, Result<GetGraphDataResult>> 37 + { 38 + constructor(private graphQueryRepo: IGraphQueryRepository) {} 39 + 40 + async execute(query: GetGraphDataQuery): Promise<Result<GetGraphDataResult>> { 41 + try { 42 + // Fetch all graph data 43 + const graphData = await this.graphQueryRepo.getGraphData(); 44 + 45 + return ok({ 46 + nodes: graphData.nodes, 47 + edges: graphData.edges, 48 + }); 49 + } catch (error) { 50 + return err( 51 + new Error( 52 + `Failed to retrieve graph data: ${error instanceof Error ? error.message : 'Unknown error'}`, 53 + ), 54 + ); 55 + } 56 + } 57 + }
+34
src/modules/cards/domain/IGraphQueryRepository.ts
··· 1 + // DTOs for graph visualization 2 + export interface GraphNodeDTO { 3 + id: string; // Unique identifier for the node 4 + type: 'USER' | 'URL' | 'COLLECTION' | 'NOTE'; 5 + label: string; // Display name/title 6 + metadata: Record<string, any>; // Type-specific data (handle, url, description, etc.) 7 + } 8 + 9 + export interface GraphEdgeDTO { 10 + id: string; // Unique identifier for the edge 11 + source: string; // Source node ID 12 + target: string; // Target node ID 13 + type: 14 + | 'USER_FOLLOWS_USER' 15 + | 'USER_FOLLOWS_COLLECTION' 16 + | 'USER_AUTHORED_URL' 17 + | 'NOTE_REFERENCES_URL' 18 + | 'COLLECTION_CONTAINS_URL' 19 + | 'URL_CONNECTS_URL'; 20 + metadata?: Record<string, any>; // Optional edge data (connection type, added date, etc.) 21 + } 22 + 23 + export interface GraphDataDTO { 24 + nodes: GraphNodeDTO[]; 25 + edges: GraphEdgeDTO[]; 26 + } 27 + 28 + export interface IGraphQueryRepository { 29 + /** 30 + * Get all nodes and edges for the global graph visualization 31 + * Returns the complete graph structure with all relationships 32 + */ 33 + getGraphData(): Promise<GraphDataDTO>; 34 + }
+24
src/modules/cards/infrastructure/http/controllers/GetGraphDataController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Response } from 'express'; 3 + import { GetGraphDataUseCase } from '../../../application/useCases/queries/GetGraphDataUseCase'; 4 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + 6 + export class GetGraphDataController extends Controller { 7 + constructor(private getGraphDataUseCase: GetGraphDataUseCase) { 8 + super(); 9 + } 10 + 11 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 12 + try { 13 + const result = await this.getGraphDataUseCase.execute({}); 14 + 15 + if (result.isErr()) { 16 + return this.fail(res, result.error); 17 + } 18 + 19 + return this.ok(res, result.value); 20 + } catch (error: any) { 21 + return this.handleError(res, error); 22 + } 23 + } 24 + }
+18
src/modules/cards/infrastructure/http/routes/graphRoutes.ts
··· 1 + import { Router } from 'express'; 2 + import { GetGraphDataController } from '../controllers/GetGraphDataController'; 3 + import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; 4 + 5 + export function createGraphRoutes( 6 + authMiddleware: AuthMiddleware, 7 + getGraphDataController: GetGraphDataController, 8 + ): Router { 9 + const router = Router(); 10 + 11 + // Query routes 12 + // GET /api/graph/data - Get all nodes and edges for graph visualization 13 + router.get('/data', authMiddleware.optionalAuth(), (req, res) => 14 + getGraphDataController.execute(req, res), 15 + ); 16 + 17 + return router; 18 + }
+18
src/modules/cards/infrastructure/repositories/DrizzleGraphQueryRepository.ts
··· 1 + import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 2 + import { 3 + IGraphQueryRepository, 4 + GraphDataDTO, 5 + } from '../../domain/IGraphQueryRepository'; 6 + import { GraphQueryService } from './query-services/GraphQueryService'; 7 + 8 + export class DrizzleGraphQueryRepository implements IGraphQueryRepository { 9 + private graphQueryService: GraphQueryService; 10 + 11 + constructor(private db: PostgresJsDatabase) { 12 + this.graphQueryService = new GraphQueryService(db); 13 + } 14 + 15 + async getGraphData(): Promise<GraphDataDTO> { 16 + return this.graphQueryService.getGraphData(); 17 + } 18 + }
+312
src/modules/cards/infrastructure/repositories/query-services/GraphQueryService.ts
··· 1 + import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 2 + import { eq, and, sql } from 'drizzle-orm'; 3 + import { 4 + GraphNodeDTO, 5 + GraphEdgeDTO, 6 + GraphDataDTO, 7 + } from '../../../domain/IGraphQueryRepository'; 8 + import { cards } from '../schema/card.sql'; 9 + import { collections, collectionCards } from '../schema/collection.sql'; 10 + import { connections } from '../schema/connection.sql'; 11 + import { follows } from '../../../../user/infrastructure/repositories/schema/follows.sql'; 12 + import { users } from '../../../../user/infrastructure/repositories/schema/user.sql'; 13 + 14 + export class GraphQueryService { 15 + constructor(private db: PostgresJsDatabase) {} 16 + 17 + async getGraphData(): Promise<GraphDataDTO> { 18 + // Fetch all data in parallel 19 + const [ 20 + userNodes, 21 + urlNodes, 22 + collectionNodes, 23 + noteNodes, 24 + userFollowEdges, 25 + collectionFollowEdges, 26 + authorshipEdges, 27 + noteUrlEdges, 28 + collectionUrlEdges, 29 + urlConnectionEdges, 30 + ] = await Promise.all([ 31 + this.getUserNodes(), 32 + this.getUrlNodes(), 33 + this.getCollectionNodes(), 34 + this.getNoteNodes(), 35 + this.getUserFollowEdges(), 36 + this.getCollectionFollowEdges(), 37 + this.getAuthorshipEdges(), 38 + this.getNoteUrlEdges(), 39 + this.getCollectionUrlEdges(), 40 + this.getUrlConnectionEdges(), 41 + ]); 42 + 43 + // Combine all nodes and edges 44 + const nodes = [...userNodes, ...urlNodes, ...collectionNodes, ...noteNodes]; 45 + 46 + const edges = [ 47 + ...userFollowEdges, 48 + ...collectionFollowEdges, 49 + ...authorshipEdges, 50 + ...noteUrlEdges, 51 + ...collectionUrlEdges, 52 + ...urlConnectionEdges, 53 + ]; 54 + 55 + return { nodes, edges }; 56 + } 57 + 58 + private async getUserNodes(): Promise<GraphNodeDTO[]> { 59 + const results = await this.db 60 + .select({ 61 + id: users.id, 62 + handle: users.handle, 63 + }) 64 + .from(users); 65 + 66 + return results.map((row) => ({ 67 + id: `user:${row.id}`, 68 + type: 'USER' as const, 69 + label: row.handle || row.id, 70 + metadata: { 71 + did: row.id, 72 + handle: row.handle, 73 + }, 74 + })); 75 + } 76 + 77 + private async getUrlNodes(): Promise<GraphNodeDTO[]> { 78 + const results = await this.db 79 + .select({ 80 + id: cards.id, 81 + url: cards.url, 82 + contentData: cards.contentData, 83 + urlType: cards.urlType, 84 + }) 85 + .from(cards) 86 + .where(and(eq(cards.type, 'URL'), sql`${cards.url} IS NOT NULL`)); 87 + 88 + return results.map((row) => { 89 + const contentData = row.contentData as any; 90 + const title = contentData?.title || row.url || 'Untitled URL'; 91 + 92 + return { 93 + id: `url:${row.url}`, 94 + type: 'URL' as const, 95 + label: title, 96 + metadata: { 97 + cardId: row.id, 98 + url: row.url, 99 + urlType: row.urlType, 100 + title, 101 + description: contentData?.description, 102 + imageUrl: contentData?.imageUrl, 103 + }, 104 + }; 105 + }); 106 + } 107 + 108 + private async getCollectionNodes(): Promise<GraphNodeDTO[]> { 109 + const results = await this.db 110 + .select({ 111 + id: collections.id, 112 + name: collections.name, 113 + description: collections.description, 114 + authorId: collections.authorId, 115 + cardCount: collections.cardCount, 116 + }) 117 + .from(collections); 118 + 119 + return results.map((row) => ({ 120 + id: `collection:${row.id}`, 121 + type: 'COLLECTION' as const, 122 + label: row.name, 123 + metadata: { 124 + collectionId: row.id, 125 + name: row.name, 126 + description: row.description, 127 + authorId: row.authorId, 128 + cardCount: row.cardCount, 129 + }, 130 + })); 131 + } 132 + 133 + private async getNoteNodes(): Promise<GraphNodeDTO[]> { 134 + const results = await this.db 135 + .select({ 136 + id: cards.id, 137 + contentData: cards.contentData, 138 + authorId: cards.authorId, 139 + }) 140 + .from(cards) 141 + .where(eq(cards.type, 'NOTE')); 142 + 143 + return results.map((row) => { 144 + const contentData = row.contentData as any; 145 + const noteText = contentData?.note || ''; 146 + const preview = 147 + noteText.substring(0, 50) + (noteText.length > 50 ? '...' : ''); 148 + 149 + return { 150 + id: `note:${row.id}`, 151 + type: 'NOTE' as const, 152 + label: preview || 'Note', 153 + metadata: { 154 + cardId: row.id, 155 + note: noteText, 156 + authorId: row.authorId, 157 + }, 158 + }; 159 + }); 160 + } 161 + 162 + private async getUserFollowEdges(): Promise<GraphEdgeDTO[]> { 163 + const results = await this.db 164 + .select({ 165 + followerId: follows.followerId, 166 + targetId: follows.targetId, 167 + createdAt: follows.createdAt, 168 + }) 169 + .from(follows) 170 + .where(eq(follows.targetType, 'user')); 171 + 172 + return results.map((row) => ({ 173 + id: `follow-user:${row.followerId}:${row.targetId}`, 174 + source: `user:${row.followerId}`, 175 + target: `user:${row.targetId}`, 176 + type: 'USER_FOLLOWS_USER' as const, 177 + metadata: { 178 + createdAt: row.createdAt.toISOString(), 179 + }, 180 + })); 181 + } 182 + 183 + private async getCollectionFollowEdges(): Promise<GraphEdgeDTO[]> { 184 + const results = await this.db 185 + .select({ 186 + followerId: follows.followerId, 187 + targetId: follows.targetId, 188 + createdAt: follows.createdAt, 189 + }) 190 + .from(follows) 191 + .where(eq(follows.targetType, 'collection')); 192 + 193 + return results.map((row) => ({ 194 + id: `follow-collection:${row.followerId}:${row.targetId}`, 195 + source: `user:${row.followerId}`, 196 + target: `collection:${row.targetId}`, 197 + type: 'USER_FOLLOWS_COLLECTION' as const, 198 + metadata: { 199 + createdAt: row.createdAt.toISOString(), 200 + }, 201 + })); 202 + } 203 + 204 + private async getAuthorshipEdges(): Promise<GraphEdgeDTO[]> { 205 + const results = await this.db 206 + .select({ 207 + authorId: cards.authorId, 208 + url: cards.url, 209 + createdAt: cards.createdAt, 210 + }) 211 + .from(cards) 212 + .where(and(eq(cards.type, 'URL'), sql`${cards.url} IS NOT NULL`)); 213 + 214 + return results.map((row) => ({ 215 + id: `authorship:${row.authorId}:${row.url}`, 216 + source: `user:${row.authorId}`, 217 + target: `url:${row.url}`, 218 + type: 'USER_AUTHORED_URL' as const, 219 + metadata: { 220 + createdAt: row.createdAt.toISOString(), 221 + }, 222 + })); 223 + } 224 + 225 + private async getNoteUrlEdges(): Promise<GraphEdgeDTO[]> { 226 + // Join notes with their parent URL cards using raw SQL for self-join 227 + const results = await this.db.execute<{ 228 + note_id: string; 229 + parent_url: string; 230 + created_at: Date; 231 + }>(sql` 232 + SELECT 233 + note_cards.id as note_id, 234 + parent_cards.url as parent_url, 235 + note_cards.created_at 236 + FROM cards as note_cards 237 + INNER JOIN cards as parent_cards 238 + ON note_cards.parent_card_id = parent_cards.id 239 + AND parent_cards.type = 'URL' 240 + WHERE note_cards.type = 'NOTE' 241 + AND parent_cards.url IS NOT NULL 242 + `); 243 + 244 + return results.map((row) => ({ 245 + id: `note-url:${row.note_id}:${row.parent_url}`, 246 + source: `note:${row.note_id}`, 247 + target: `url:${row.parent_url}`, 248 + type: 'NOTE_REFERENCES_URL' as const, 249 + metadata: { 250 + createdAt: row.created_at.toISOString(), 251 + }, 252 + })); 253 + } 254 + 255 + private async getCollectionUrlEdges(): Promise<GraphEdgeDTO[]> { 256 + const results = await this.db 257 + .select({ 258 + collectionId: collectionCards.collectionId, 259 + cardId: collectionCards.cardId, 260 + url: cards.url, 261 + addedAt: collectionCards.addedAt, 262 + addedBy: collectionCards.addedBy, 263 + }) 264 + .from(collectionCards) 265 + .innerJoin(cards, eq(collectionCards.cardId, cards.id)) 266 + .where(and(eq(cards.type, 'URL'), sql`${cards.url} IS NOT NULL`)); 267 + 268 + return results.map((row) => ({ 269 + id: `collection-url:${row.collectionId}:${row.url}`, 270 + source: `collection:${row.collectionId}`, 271 + target: `url:${row.url}`, 272 + type: 'COLLECTION_CONTAINS_URL' as const, 273 + metadata: { 274 + addedAt: row.addedAt.toISOString(), 275 + addedBy: row.addedBy, 276 + }, 277 + })); 278 + } 279 + 280 + private async getUrlConnectionEdges(): Promise<GraphEdgeDTO[]> { 281 + const results = await this.db 282 + .select({ 283 + id: connections.id, 284 + sourceValue: connections.sourceValue, 285 + targetValue: connections.targetValue, 286 + connectionType: connections.connectionType, 287 + note: connections.note, 288 + curatorId: connections.curatorId, 289 + createdAt: connections.createdAt, 290 + }) 291 + .from(connections) 292 + .where( 293 + and( 294 + eq(connections.sourceType, 'URL'), 295 + eq(connections.targetType, 'URL'), 296 + ), 297 + ); 298 + 299 + return results.map((row) => ({ 300 + id: `connection:${row.id}`, 301 + source: `url:${row.sourceValue}`, 302 + target: `url:${row.targetValue}`, 303 + type: 'URL_CONNECTS_URL' as const, 304 + metadata: { 305 + connectionType: row.connectionType, 306 + note: row.note, 307 + curatorId: row.curatorId, 308 + createdAt: row.createdAt.toISOString(), 309 + }, 310 + })); 311 + } 312 + }
+24
src/modules/cards/tests/utils/InMemoryGraphQueryRepository.ts
··· 1 + import { 2 + IGraphQueryRepository, 3 + GraphDataDTO, 4 + } from '../../domain/IGraphQueryRepository'; 5 + 6 + export class InMemoryGraphQueryRepository implements IGraphQueryRepository { 7 + private static instance: InMemoryGraphQueryRepository; 8 + 9 + private constructor() {} 10 + 11 + public static getInstance(): InMemoryGraphQueryRepository { 12 + if (!InMemoryGraphQueryRepository.instance) { 13 + InMemoryGraphQueryRepository.instance = 14 + new InMemoryGraphQueryRepository(); 15 + } 16 + return InMemoryGraphQueryRepository.instance; 17 + } 18 + 19 + async getGraphData(): Promise<GraphDataDTO> { 20 + // For in-memory implementation, return empty graph 21 + // In a real test scenario, you would populate this with test data 22 + return { nodes: [], edges: [] }; 23 + } 24 + }
+7
src/shared/infrastructure/http/app.ts
··· 6 6 import { createAtprotoRoutes } from '../../../modules/atproto/infrastructure/atprotoRoutes'; 7 7 import { createCardsModuleRoutes } from '../../../modules/cards/infrastructure/http/routes'; 8 8 import { createConnectionRoutes } from '../../../modules/cards/infrastructure/http/routes/connectionRoutes'; 9 + import { createGraphRoutes } from '../../../modules/cards/infrastructure/http/routes/graphRoutes'; 9 10 import { createFeedRoutes } from '../../../modules/feeds/infrastructure/http/routes/feedRoutes'; 10 11 import { createSearchRoutes } from '../../../modules/search/infrastructure/http/routes/searchRoutes'; 11 12 import { createNotificationRoutes } from '../../../modules/notifications/infrastructure/http/routes/notificationRoutes'; ··· 141 142 controllers.getBackwardConnectionsForUrlController, 142 143 ); 143 144 145 + const graphRouter = createGraphRoutes( 146 + services.authMiddleware, 147 + controllers.getGraphDataController, 148 + ); 149 + 144 150 const feedRouter = createFeedRoutes( 145 151 services.authMiddleware, 146 152 controllers.getGlobalFeedController, ··· 173 179 app.use('/atproto', atprotoRouter); 174 180 app.use('/api', cardsRouter); 175 181 app.use('/api/connections', connectionRouter); 182 + app.use('/api/graph', graphRouter); 176 183 app.use('/api/feeds', feedRouter); 177 184 app.use('/api/search', searchRouter); 178 185 app.use('/api/notifications', notificationRouter);
+8
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 62 62 import { GetForwardConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController'; 63 63 import { GetBackwardConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetBackwardConnectionsForUrlController'; 64 64 import { SearchUrlsController } from '../../../../modules/cards/infrastructure/http/controllers/SearchUrlsController'; 65 + import { GetGraphDataController } from '../../../../modules/cards/infrastructure/http/controllers/GetGraphDataController'; 65 66 import { CookieService } from '../services/CookieService'; 66 67 67 68 export interface Controllers { ··· 118 119 deleteConnectionController: DeleteConnectionController; 119 120 getForwardConnectionsForUrlController: GetForwardConnectionsForUrlController; 120 121 getBackwardConnectionsForUrlController: GetBackwardConnectionsForUrlController; 122 + // Graph controllers 123 + getGraphDataController: GetGraphDataController; 121 124 // Search controllers 122 125 searchUrlsController: SearchUrlsController; 123 126 // Feed controllers ··· 309 312 new GetBackwardConnectionsForUrlController( 310 313 useCases.getBackwardConnectionsForUrlUseCase, 311 314 ), 315 + 316 + // Graph controllers 317 + getGraphDataController: new GetGraphDataController( 318 + useCases.getGraphDataUseCase, 319 + ), 312 320 313 321 // Search controllers 314 322 searchUrlsController: new SearchUrlsController(
+7
src/shared/infrastructure/http/factories/RepositoryFactory.ts
··· 51 51 import { IFollowsRepository } from '../../../../modules/user/domain/repositories/IFollowsRepository'; 52 52 import { DrizzleFollowsRepository } from '../../../../modules/user/infrastructure/repositories/DrizzleFollowsRepository'; 53 53 import { InMemoryFollowsRepository } from '../../../../modules/user/tests/infrastructure/InMemoryFollowsRepository'; 54 + import { IGraphQueryRepository } from '../../../../modules/cards/domain/IGraphQueryRepository'; 55 + import { DrizzleGraphQueryRepository } from '../../../../modules/cards/infrastructure/repositories/DrizzleGraphQueryRepository'; 56 + import { InMemoryGraphQueryRepository } from '../../../../modules/cards/tests/utils/InMemoryGraphQueryRepository'; 54 57 55 58 export interface Repositories { 56 59 userRepository: IUserRepository; ··· 61 64 collectionQueryRepository: ICollectionQueryRepository; 62 65 connectionRepository: IConnectionRepository; 63 66 connectionQueryRepository: IConnectionQueryRepository; 67 + graphQueryRepository: IGraphQueryRepository; 64 68 appPasswordSessionRepository: IAppPasswordSessionRepository; 65 69 feedRepository: IFeedRepository; 66 70 followsRepository: IFollowsRepository; ··· 93 97 const connectionQueryRepository = new InMemoryConnectionQueryRepository( 94 98 connectionRepository, 95 99 ); 100 + const graphQueryRepository = InMemoryGraphQueryRepository.getInstance(); 96 101 const appPasswordSessionRepository = 97 102 InMemoryAppPasswordSessionRepository.getInstance(); 98 103 const feedRepository = InMemoryFeedRepository.getInstance(); ··· 119 124 collectionQueryRepository, 120 125 connectionRepository, 121 126 connectionQueryRepository, 127 + graphQueryRepository, 122 128 appPasswordSessionRepository, 123 129 feedRepository, 124 130 followsRepository, ··· 146 152 collectionQueryRepository: new DrizzleCollectionQueryRepository(db), 147 153 connectionRepository: new DrizzleConnectionRepository(db), 148 154 connectionQueryRepository: new DrizzleConnectionQueryRepository(db), 155 + graphQueryRepository: new DrizzleGraphQueryRepository(db), 149 156 appPasswordSessionRepository: new DrizzleAppPasswordSessionRepository(db), 150 157 feedRepository: new DrizzleFeedRepository(db), 151 158 followsRepository: new DrizzleFollowsRepository(db),
+8
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 73 73 import { GetCollectionFollowersCountUseCase } from '../../../../modules/user/application/useCases/queries/GetCollectionFollowersCountUseCase'; 74 74 import { GetCollectionContributorsUseCase } from '../../../../modules/cards/application/useCases/queries/GetCollectionContributorsUseCase'; 75 75 import { SearchUrlsUseCase } from '../../../../modules/cards/application/useCases/queries/SearchUrlsUseCase'; 76 + import { GetGraphDataUseCase } from '../../../../modules/cards/application/useCases/queries/GetGraphDataUseCase'; 76 77 77 78 export interface WorkerUseCases { 78 79 addActivityToFeedUseCase: AddActivityToFeedUseCase; ··· 148 149 createConnectionUseCase: CreateConnectionUseCase; 149 150 updateConnectionUseCase: UpdateConnectionUseCase; 150 151 deleteConnectionUseCase: DeleteConnectionUseCase; 152 + // Graph use cases 153 + getGraphDataUseCase: GetGraphDataUseCase; 151 154 // Search use cases 152 155 searchUrlsUseCase: SearchUrlsUseCase; 153 156 // Feed use cases ··· 426 429 repositories.connectionRepository, 427 430 services.connectionPublisher, 428 431 services.eventPublisher, 432 + ), 433 + 434 + // Graph use cases 435 + getGraphDataUseCase: new GetGraphDataUseCase( 436 + repositories.graphQueryRepository, 429 437 ), 430 438 431 439 // Search use cases
+5
src/types/src/api/requests.ts
··· 352 352 identifier: string; // Can be DID or handle 353 353 connectionTypes?: ConnectionType[]; 354 354 } 355 + 356 + // Graph request types 357 + export interface GetGraphDataParams { 358 + // No parameters needed for global graph in v1 359 + }
+27
src/types/src/api/responses.ts
··· 468 468 pagination: Pagination; 469 469 sorting: ConnectionSorting; 470 470 } 471 + 472 + // Graph response types 473 + export interface GraphNode { 474 + id: string; 475 + type: 'USER' | 'URL' | 'COLLECTION' | 'NOTE'; 476 + label: string; 477 + metadata: Record<string, any>; 478 + } 479 + 480 + export interface GraphEdge { 481 + id: string; 482 + source: string; 483 + target: string; 484 + type: 485 + | 'USER_FOLLOWS_USER' 486 + | 'USER_FOLLOWS_COLLECTION' 487 + | 'USER_AUTHORED_URL' 488 + | 'NOTE_REFERENCES_URL' 489 + | 'COLLECTION_CONTAINS_URL' 490 + | 'URL_CONNECTS_URL'; 491 + metadata?: Record<string, any>; 492 + } 493 + 494 + export interface GetGraphDataResponse { 495 + nodes: GraphNode[]; 496 + edges: GraphEdge[]; 497 + }
+7
src/webapp/api-client/ApiClient.ts
··· 117 117 // Search types 118 118 SearchUrlsParams, 119 119 SearchUrlsResponse, 120 + // Graph types 121 + GetGraphDataResponse, 120 122 } from '@semble/types'; 121 123 122 124 // Main API Client class using composition ··· 514 516 // Search operations 515 517 async searchUrls(params: SearchUrlsParams): Promise<SearchUrlsResponse> { 516 518 return this.queryClient.searchUrls(params); 519 + } 520 + 521 + // Graph operations 522 + async getGraphData(): Promise<GetGraphDataResponse> { 523 + return this.queryClient.getGraphData(); 517 524 } 518 525 } 519 526
+8
src/webapp/api-client/clients/QueryClient.ts
··· 56 56 GetBackwardConnectionsForUrlResponse, 57 57 SearchUrlsParams, 58 58 SearchUrlsResponse, 59 + GetGraphDataParams, 60 + GetGraphDataResponse, 59 61 } from '@semble/types'; 60 62 61 63 export class QueryClient extends BaseClient { ··· 548 550 'GET', 549 551 `/api/cards/search?${searchParams}`, 550 552 ); 553 + } 554 + 555 + async getGraphData( 556 + params?: GetGraphDataParams, 557 + ): Promise<GetGraphDataResponse> { 558 + return this.request<GetGraphDataResponse>('GET', '/api/graph/data'); 551 559 } 552 560 }