GraphQL Integration
GraphQL Integration
GraphQL Operations Examples
Example Query (Read Data)
query getObjectDetails($objectId: ID!) {
object(id: $objectId) {
id
name
description
owner
statuses
partOf {
id
name
}
spatialChildren {
id
name
}
}
}
Example Mutation (Write Data)
mutation moveObject($objectId: ID!, $newContainerId: ID!) {
moveObject(objectId: $objectId, newContainerId: $newContainerId) {
id
spatialParent {
id
name
}
}
}
Example Subscription (Real-time Updates)
subscription onObjectUpdate($objectId: ID!) {
objectUpdated(id: $objectId) {
id
name
statuses
}
}
Backend Integration
- Authentication: JWT tokens via Supabase, passed in GraphQL context
- API Layer: GraphQL (e.g., Ariadne/Strawberry for Python)
- Data Source: Neo4j Aura
- File Upload: Handled via a GraphQL multipart request spec implementation.
Lazy Loading of Large Object Hierarchies
A key performance requirement is to handle large and deeply nested object graphs without long initial load times. Loading an entire object tree at once is not feasible as it would be slow and consume excessive memory.
The required approach is Lazy Loading. The client must load the hierarchy progressively as the user explores it.
Lazy Loading Flow
- Initial Fetch: On page load, the client fetches only the top-level objects (those with no parent container). To enable the UI to show an “expand” icon, the query should include a boolean field like
hasChildren. - User-Triggered Fetch: When the user clicks to expand an object, the client executes a new GraphQL query to fetch the direct children of that specific object.
- State Update: The results are merged into the local state, and the UI re-renders to show the newly loaded objects.
Example GraphQL Queries
1. Initial Query for Top-Level Objects
query GetTopLevelObjects {
objects(filter: { containerId: null }) {
id
name
hasChildren
}
}
2. On-Demand Query for an Object’s Children
query GetChildrenOfObject($parentId: ID!) {
objects(filter: { containerId: $parentId }) {
id
name
hasChildren
}
}
This pattern ensures the application remains fast and responsive, regardless of the overall size of the object graph.
Common Backend Issues That Break GraphQL
🚨 CRITICAL IMPORT ERRORS: The most common cause of GraphQL 500 errors is incorrect imports in the backend:
# ❌ WRONG - These cause ImportError crashes and 500 errors
from .db_postgres import create_async_engine # Function doesn't exist here
from .db_neo4j import AsyncGraphDatabase # Function doesn't exist here
# ✅ CORRECT - Import from actual packages
from sqlalchemy.ext.asyncio import create_async_engine
from neo4j import AsyncGraphDatabase
Symptoms: All endpoints return 500 errors, CORS preflight failures, no GraphQL access.
GraphQL Parameter Naming Convention
⚠️ Important: GraphQL uses camelCase for parameters, but Python resolvers must match exactly.
🚨 CRITICAL: Before writing database queries, verify column names against ../database/SCHEMA-VERIFICATION.md. We had a production bug from using wrong column names!
Correct Pattern:
# GraphQL Schema (camelCase)
mutation updateObjectStatuses(objectId: ID!, statusKeys: [String!]!) { ... }
# Python Resolver (must match GraphQL exactly)
async def resolve_update_object_statuses(
_: Any, info: GraphQLResolveInfo, objectId: str, statusKeys: List[str]
) -> Dict[str, Any]:
Common Mistake:
# ❌ This will fail with "unexpected keyword argument" error
async def resolve_update_object_statuses(
_: Any, info: GraphQLResolveInfo, object_id: str, status_keys: List[str] # Wrong!
) -> Dict[str, Any]:
Prevention Checklist:
- Always match GraphQL schema parameter names exactly in Python resolvers
- Test mutations immediately after implementation
- Check browser console for parameter mismatch errors
Status Management Integration
The status system provides a comprehensive GraphQL API for managing object statuses. Frontend applications should load status definitions on startup and use them throughout the application.
Loading Status Definitions
// Load status definitions once on app initialization
const GET_STATUS_DEFINITIONS = gql`
query GetStatusDefinitions {
statusDefinitions {
key
name
description
color
category
isTerminal
conflictsWith
requiresActiveFalse
}
}
`;
const { data: statusDefinitions } = useQuery(GET_STATUS_DEFINITIONS);
// Create lookup maps for efficient access
const statusByKey = useMemo(() =>
statusDefinitions?.statusDefinitions.reduce((acc, status) => {
acc[status.key] = status;
return acc;
}, {}) || {}, [statusDefinitions]
);
Status Validation
// Validate status combinations before submission
const VALIDATE_STATUS_COMBINATION = gql`
query ValidateStatusCombination($statusKeys: [String!]!) {
validateStatusCombination(statusKeys: $statusKeys) {
valid
errors
warnings
}
}
`;
const validateStatuses = async (statusKeys: string[]) => {
const { data } = await validateStatusCombination({
variables: { statusKeys }
});
return data.validateStatusCombination;
};
Status Category Filtering
// Get statuses by category for organized UI
const GET_COMMERCE_STATUSES = gql`
query GetCommerceStatuses {
statusesByCategory(category: "commerce") {
key
name
description
color
}
}
`;
Real-time Status Updates
// Subscribe to status changes
const STATUS_UPDATED = gql`
subscription ObjectStatusUpdated($objectId: ID!) {
objectUpdated(id: $objectId) {
id
statuses
}
}
`;
Image Management Integration
The backend provides comprehensive image management through GraphQL mutations with file upload support.
Upload New Images (Direct-to-Storage)
import { supabase } from '@/lib/supabase';
// Helper that wraps the new REST endpoint
const registerImage = async (
objectId: string,
publicUrl: string,
storagePath: string,
accessToken: string
) => {
const backend = import.meta.env.VITE_GRAPHQL_ENDPOINT!.replace(/\/graphql\/?$/, '');
const res = await fetch(`${backend}/register-image/${objectId}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ public_url: publicUrl, storage_path: storagePath }),
});
if (!res.ok) {
throw new Error(await res.text());
}
return (await res.json()).object as {
id: string;
imageUrls: string[];
mainImageUrl: string | null;
};
};
export const uploadImages = async (objectId: string, files: FileList) => {
const session = (await supabase.auth.getSession()).data.session;
if (!session?.access_token) throw new Error('No JWT');
const uploaded: string[] = [];
for (const file of Array.from(files)) {
const path = `objects/ObjectInstance/${objectId}/${Date.now()}-${file.name}`;
const { error } = await supabase.storage
.from('object-images')
.upload(path, file, { contentType: file.type });
if (error) throw error;
const {
data: { publicUrl },
} = supabase.storage.from('object-images').getPublicUrl(path);
const obj = await registerImage(objectId, publicUrl, path, session.access_token);
uploaded.push(publicUrl);
// update UI state with `obj.imageUrls` / `obj.mainImageUrl`
}
return uploaded;
};
Delete Specific Images
const DELETE_OBJECT_IMAGES = gql`
mutation deleteObjectImages($objectId: ID!, $imageUrls: [String!]!) {
deleteObjectImages(objectId: $objectId, imageUrls: $imageUrls) {
id
imageUrls
mainImageUrl
}
}
`;
const [deleteImages] = useMutation(DELETE_OBJECT_IMAGES);
const handleImageDelete = async (objectId: string, urlsToDelete: string[]) => {
try {
const { data } = await deleteImages({
variables: {
objectId,
imageUrls: urlsToDelete
}
});
// Update local state
console.log('Remaining images:', data.deleteObjectImages.imageUrls);
} catch (error) {
console.error('Delete failed:', error);
}
};
Update Images (Add + Delete atomically)
const UPDATE_OBJECT_IMAGES = gql`
mutation updateObjectImages($objectId: ID!, $newImages: [Upload!], $deleteImageUrls: [String!]) {
updateObjectImages(objectId: $objectId, newImages: $newImages, deleteImageUrls: $deleteImageUrls) {
id
imageUrls
mainImageUrl
}
}
`;
const [updateImages] = useMutation(UPDATE_OBJECT_IMAGES);
const handleImageUpdate = async (objectId: string, newFiles?: FileList, deleteUrls?: string[]) => {
try {
const { data } = await updateImages({
variables: {
objectId,
newImages: newFiles ? Array.from(newFiles) : undefined,
deleteImageUrls: deleteUrls || []
}
});
// Update local state
console.log('Updated images:', data.updateObjectImages.imageUrls);
} catch (error) {
console.error('Update failed:', error);
}
};
Image Management Best Practices
- File Validation: Validate file types and sizes before upload ```typescript const validateImages = (files: FileList): boolean => { const allowedTypes = [‘image/jpeg’, ‘image/png’, ‘image/webp’]; const maxSize = 5 * 1024 * 1024; // 5MB
return Array.from(files).every(file => allowedTypes.includes(file.type) && file.size <= maxSize ); };
2. **Loading States**: Show progress during operations
```typescript
const [uploading, setUploading] = useState(false);
const handleUpload = async (files: FileList) => {
if (!validateImages(files)) {
toast.error('Invalid file type or size');
return;
}
setUploading(true);
try {
await uploadImages({ variables: { objectId, images: Array.from(files) } });
toast.success('Images uploaded successfully');
} catch (error) {
toast.error('Upload failed');
} finally {
setUploading(false);
}
};
- State Management: Update Apollo cache or local state
const [uploadImages] = useMutation(UPLOAD_OBJECT_IMAGES, { update: (cache, { data }) => { // Update the myObjects query cache const existingObjects = cache.readQuery({ query: MY_OBJECTS }); const updatedObjects = existingObjects.myObjects.map(obj => obj.id === data.uploadObjectImages.id ? data.uploadObjectImages : obj ); cache.writeQuery({ query: MY_OBJECTS, data: { myObjects: updatedObjects } }); } });
TypeScript Types for Images
// Generated types from GraphQL schema
type ObjectInstance = {
id: string;
name: string;
description?: string;
mainImageUrl?: string;
imageUrls: string[];
// ... other fields
};
// File upload type
type ImageUploadInput = {
objectId: string;
images: File[];
};
// Image delete input
type ImageDeleteInput = {
objectId: string;
imageUrls: string[];
};
🚨 NOTE (2025-06-23)
All binaries are now uploaded directly to Supabase Storage from the browser. After each upload the client callsPOST /register-image/{objectId}with{ public_url, storage_path }so the backend can insert the row and updateis_main. No raw image data ever transits the Vercel function, eliminating the 4.5 MB body-size ceiling.
CDN Image Transformations (Supabase Storage)
Supabase’s CDN can resize, crop and convert images on-the-fly – perfect for thumbnails, gallery previews and hero banners. Always request a transformed version instead of downloading the original multi-MB photo.
import { supabase } from '@/lib/supabase';
// Fetch a 400 × 400 contained thumbnail as a Blob, then create an object URL
export const getThumbnailUrl = async (bucket: string, path: string) => {
const { data, error } = await supabase.storage.from(bucket).download(path, {
transform: {
width: 400,
height: 400,
resize: 'contain', // 'cover' | 'fill' | 'inside' | 'outside'
quality: 80,
format: 'webp',
},
});
if (error) throw error;
return URL.createObjectURL(data);
};
| Option | Type | Default | Description |
|---|---|---|---|
width |
number |
original | Target width in px |
height |
number |
original | Target height in px |
resize |
'contain' \| 'cover' \| 'fill' \| 'inside' \| 'outside' |
'cover' |
How to fit the image into the box |
quality |
number 1-100 |
80 | JPEG/WebP quality |
format |
'jpg' \| 'png' \| 'webp' |
source | Force output format |
Guidelines
- Use small
width/heightfor list thumbnails (e.g., 200×200). - Prefer
resize: 'cover'for square avatars andobject-fit: coverin CSS. - Store only the original; transformed variants are cached automatically by Supabase’s CDN, so there’s no extra storage cost.
- During active development append
?v=${Date.now()}to bypass CDN cache when tweaking images.
See Supabase docs → Storage → Image Transformations for the full option set.
🚨 FRONTEND IMAGE HANDLING - CRITICAL PATTERNS
MANDATORY: Always Use Transformations for Display
NEVER display raw Supabase Storage URLs in React components. This kills performance by downloading 5-10MB photos for thumbnails.
❌ Wrong - Performance Killer
// DON'T DO THIS - Downloads multi-MB images for small thumbnails
<img src={object.mainImageUrl} className="w-32 h-32" />
✅ Correct - Optimized Performance
// DO THIS - Downloads optimized thumbnail
const { transformedUrl } = useTransformedImage(object.mainImageUrl, {
width: 200, height: 200, resize: 'contain'
});
<img src={transformedUrl} className="w-32 h-32" />
Required Hook Pattern: useTransformedImage
Import and use for ALL image displays:
import { useTransformedImage } from '@/hooks/useTransformedImage';
const { transformedUrl, loading, error } = useTransformedImage(imageUrl, {
width: 400,
height: 300,
resize: 'contain',
quality: 80,
format: 'webp'
});
if (loading) return <div className="animate-spin">Loading...</div>;
if (error) console.warn('Transform failed, using fallback');
return <img src={transformedUrl} alt="Object" loading="lazy" />;
Standard UI Patterns
1. Grid Thumbnails (200×200)
const ObjectGridItem = ({ object }) => {
const { transformedUrl, loading } = useTransformedImage(object.mainImageUrl, {
width: 200, height: 200, resize: 'contain', format: 'webp'
});
return (
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
{loading ? (
<div className="w-full h-full animate-pulse bg-gray-200" />
) : (
<img src={transformedUrl} alt={object.name} className="w-full h-full object-contain" loading="lazy" />
)}
</div>
);
};
2. Card Images (400×300)
const ObjectCard = ({ object }) => {
const { transformedUrl } = useTransformedImage(object.mainImageUrl, {
width: 400, height: 300, resize: 'contain'
});
return (
<div className="h-48 bg-gray-100 rounded-lg overflow-hidden">
<img src={transformedUrl} alt={object.name} className="w-full h-full object-contain" />
</div>
);
};
3. Detail Views (800×600)
const ObjectDetailImage = ({ imageUrl, objectName }) => {
const { transformedUrl, loading } = useTransformedImage(imageUrl, {
width: 800, height: 600, resize: 'contain', quality: 90
});
return (
<div className="w-full h-96 bg-gray-50 rounded-lg overflow-hidden">
{loading ? <ImageSkeleton /> : (
<img src={transformedUrl} alt={objectName} className="w-full h-full object-contain cursor-zoom-in" />
)}
</div>
);
};
Performance Standards
Transform Presets (Maximize CDN Cache Hits)
export const TRANSFORM_PRESETS = {
thumbnail: { width: 200, height: 200, resize: 'contain', format: 'webp' },
card: { width: 400, height: 300, resize: 'contain', format: 'webp' },
detail: { width: 800, height: 600, resize: 'contain', quality: 90 },
avatar: { width: 100, height: 100, resize: 'cover', format: 'webp' }
} as const;
// Usage
const { transformedUrl } = useTransformedImage(imageUrl, TRANSFORM_PRESETS.thumbnail);
Required Attributes
// Always include these for performance
<img
src={transformedUrl}
alt="Object"
loading="lazy" // Essential for list performance
className="..."
/>
Error Handling Patterns
const SafeImage = ({ imageUrl, fallbackSrc = '/placeholder.svg' }) => {
const { transformedUrl, loading, error } = useTransformedImage(imageUrl);
if (error) console.warn('Transform failed:', error);
return (
<img
src={transformedUrl || fallbackSrc}
alt="Object"
onError={(e) => e.currentTarget.src = fallbackSrc}
/>
);
};
Common Mistakes to Avoid
❌ Performance Killers
// 1. Raw URLs (downloads full resolution!)
<img src={object.mainImageUrl} />
// 2. No dimensions specified (uses original size!)
const { transformedUrl } = useTransformedImage(imageUrl);
// 3. No loading states (shows broken images)
<img src={transformedUrl} />
✅ Correct Patterns
// 1. Always transform with appropriate dimensions
const { transformedUrl } = useTransformedImage(imageUrl, { width: 400, height: 300 });
// 2. Handle loading and error states
if (loading) return <Skeleton />;
if (error) return <PlaceholderImage />;
// 3. Use lazy loading for lists
<img src={transformedUrl} loading="lazy" />
Migration Checklist
When updating components that display images:
- ✅ Replace
src={imageUrl}withuseTransformedImagehook - ✅ Add appropriate width/height for UI context
- ✅ Include loading states and error handling
- ✅ Add
loading="lazy"for list items - ✅ Use WebP format where supported
- ✅ Test with slow network to verify transforms work
Development Tools
Cache Busting During Development
const options = {
width: 400, height: 300,
// Bypass CDN cache during active development
...(import.meta.env.DEV && { v: Date.now() })
};
Debug Transform Requests
// Enable in development
console.log('Transform:', { imageUrl, options, transformedUrl });
Remember: Every image in the UI must use transformations - raw URLs are banned for performance reasons!