This guide walks through adding a new first-class domain object. Use the projects / experiments / tasks entities as reference examples.
Create backend/migrations/<NNN>_<entity>.sql. Follow the existing numbering sequence.
Typical structure:
CREATE TABLE things (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
library_id TEXT REFERENCES libraries(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW()
);- Use
TEXTprimary keys. The service layer generates IDs asthing_<uuid4().hex[:10]>. - Disable RLS to match the existing schema pattern:
ALTER TABLE things DISABLE ROW LEVEL SECURITY; - Run the migration in the Supabase SQL editor in order.
Create backend/models/thing.py. All models must inherit from CamelModel.
from typing import Optional
from pydantic import Field
from models.base import CamelModel
class Thing(CamelModel):
id: str
name: str
description: Optional[str] = None
library_id: Optional[str] = None
created_at: Optional[str] = None
class ThingCreate(CamelModel):
name: str
description: Optional[str] = None
library_id: Optional[str] = None
class ThingUpdate(CamelModel):
name: Optional[str] = None
description: Optional[str] = NoneCamelModel applies alias_generator=to_camel and populate_by_name=True. This means:
- Python fields use
snake_case. - JSON serialization via
model.model_dump(by_alias=True)producescamelCase. - Supabase row validation via
Thing.model_validate(row)works withsnake_casecolumn names.
Create backend/services/thing_service.py. All database access lives here.
import uuid
from typing import Optional
from models.thing import Thing, ThingCreate, ThingUpdate
from services.db import get_client
_TABLE = "things"
def list_things(library_id: Optional[str] = None) -> list[Thing]:
q = get_client().table(_TABLE).select("*")
if library_id:
q = q.eq("library_id", library_id)
result = q.order("created_at").execute()
return [Thing.model_validate(r) for r in result.data]
def get_thing(thing_id: str) -> Optional[Thing]:
result = get_client().table(_TABLE).select("*").eq("id", thing_id).execute()
return Thing.model_validate(result.data[0]) if result.data else None
def create_thing(data: ThingCreate) -> Thing:
thing = Thing(
id=f"thing_{uuid.uuid4().hex[:10]}",
**data.model_dump(),
)
get_client().table(_TABLE).insert(thing.model_dump(by_alias=False)).execute()
return thing
def update_thing(thing_id: str, data: ThingUpdate) -> Optional[Thing]:
updates = data.model_dump(exclude_unset=True) # use exclude_unset, NOT exclude_none
if not updates:
return get_thing(thing_id)
if get_thing(thing_id) is None:
return None
get_client().table(_TABLE).update(updates).eq("id", thing_id).execute()
return get_thing(thing_id)
def delete_thing(thing_id: str) -> bool:
if get_thing(thing_id) is None:
return False
get_client().table(_TABLE).delete().eq("id", thing_id).execute()
return TrueKey rules:
- Always use
exclude_unset=True(notexclude_none) on updates so fields explicitly set toNoneare not silently dropped. - Never chain
.select()after a filter on update/delete (causesAttributeError). - Check existence before update; return
Noneif not found. - No business logic in routers — it belongs here.
Create backend/routers/things.py.
from typing import Optional
from fastapi import APIRouter, HTTPException
from models.thing import Thing, ThingCreate, ThingUpdate
from services import thing_service
router = APIRouter(prefix="/api", tags=["things"])
@router.get("/things")
def list_things(library_id: Optional[str] = None):
return [t.model_dump(by_alias=True) for t in thing_service.list_things(library_id)]
@router.post("/things", status_code=201)
def create_thing(data: ThingCreate):
return thing_service.create_thing(data).model_dump(by_alias=True)
@router.get("/things/{thing_id}")
def get_thing(thing_id: str):
thing = thing_service.get_thing(thing_id)
if not thing:
raise HTTPException(404, detail={"error": "not_found", "detail": "Thing not found"})
return thing.model_dump(by_alias=True)
@router.patch("/things/{thing_id}")
def update_thing(thing_id: str, data: ThingUpdate):
thing = thing_service.update_thing(thing_id, data)
if not thing:
raise HTTPException(404, detail={"error": "not_found", "detail": "Thing not found"})
return thing.model_dump(by_alias=True)
@router.delete("/things/{thing_id}", status_code=204)
def delete_thing(thing_id: str):
if not thing_service.delete_thing(thing_id):
raise HTTPException(404, detail={"error": "not_found", "detail": "Thing not found"})All 404s return {"error": "not_found", "detail": "..."}. All responses use model_dump(by_alias=True) to produce camelCase JSON.
In backend/app.py, import and register the router:
from routers import things # add to existing import line
app.include_router(things.router) # add after existing include_router callsIn frontend/src/services/api.js, add a new named export:
export const thingsApi = {
list: (params = {}) => {
const qs = new URLSearchParams(
Object.fromEntries(Object.entries(params).filter(([, v]) => v != null))
).toString()
return apiFetch(`/things${qs ? `?${qs}` : ''}`)
},
get: (id) => apiFetch(`/things/${id}`),
create: (data) => apiFetch('/things', { method: 'POST', body: data }),
update: (id, data) => apiFetch(`/things/${id}`, { method: 'PATCH', body: data }),
remove: (id) => apiFetch(`/things/${id}`, { method: 'DELETE' }),
}apiFetch handles Content-Type: application/json, JSON serialization, and error extraction.
Create frontend/src/pages/Things.jsx. Use the fetch-on-mount pattern:
import { useState, useEffect } from 'react'
import { thingsApi } from '../services/api'
import { useLibrary } from '../context/LibraryContext'
export default function Things() {
const { activeLibraryId } = useLibrary()
const [things, setThings] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
if (!activeLibraryId) return
setLoading(true)
thingsApi.list({ library_id: activeLibraryId })
.then(setThings)
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [activeLibraryId])
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error}</div>
return <ul>{things.map(t => <li key={t.id}>{t.name}</li>)}</ul>
}In frontend/src/App.jsx, import and register the route inside the <Layout> route:
import Things from './pages/Things'
// Inside the Route path="/" element={<Layout />} block:
<Route path="things" element={<Things />} />Add a SidebarLink in frontend/src/components/layout/Sidebar.jsx if the entity deserves top-level navigation.
- Migration file created and run in Supabase
- Pydantic model inherits
CamelModel; separate Create and Update types - Service:
list,get,create,update,delete— all use the service layer - Router: all 404s return
{"error": "not_found"}, all responses useby_alias=True -
app.py: router imported and registered -
api.js: named export withlist,get,create,update,remove - Page/component: fetch-on-mount with loading/error states
- Route registered in
App.jsx