diff --git a/README.md b/README.md
index 60e3e5d..1b031bc 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,41 @@
# Djarea
-Django + React server functions framework. RPC, not REST.
+Django + React server functions. RPC, not REST.
-You define Python functions. Djarea generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
+Write a Python function. Djarea generates a typed React hook. No routes, no serializers, no endpoint boilerplate.
```python
-# Django
-@client(context='global')
-def current_user(request) -> UserOutput:
- return UserOutput(email=request.user.email)
+@client
+def current_user(request) -> UserShape:
+ return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
```
```tsx
-// React (generated)
-const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
+const user = useCurrentUser() // typed, cached, SSR-hydrated
```
-## Packages
+The decorator is the API contract. The Shape is the query plan. The hook is generated. That's it.
-| Package | Path | Install |
-|---------|------|---------|
-| `djarea` (Python) | `django/` | `uv add "djarea[channels] @ git+..."` |
-| `@rythazhur/djarea` (TypeScript) | `react/` | `npm install @rythazhur/djarea@git+...` |
+## What Djarea does
-## Quick Start
+A `@client` function in Django becomes a callable hook in React. The function's type signature controls everything — input validation, output serialization, TypeScript types, and SQL projection.
+
+```python
+class ArticleShape(Shape[Article]):
+ id: int | None = None
+ title: str
+ author: FlatAuthorShape
+ tags: list[TagShape] = []
+```
+
+This Shape does three things at once:
+- Defines the Pydantic model for validation and serialization
+- Generates the django-readers spec, so the SQL query selects exactly these fields and nothing else
+- Produces the TypeScript type on the React side
+
+One definition. Three layers stay in sync automatically.
+
+## Quick start
### 1. Django setup
@@ -50,35 +62,24 @@ application = wrap_asgi(get_asgi_application())
```python
# myapp/djarea_clients.py
-from django.http import HttpRequest
from djarea.client import client
-from djarea.setup.registry import register
+from djarea.shapes import Shape
from pydantic import BaseModel
class EchoOutput(BaseModel):
message: str
@client
-def echo(request: HttpRequest, text: str) -> EchoOutput:
+def echo(request, text: str) -> EchoOutput:
return EchoOutput(message=text)
-
-register(echo, "echo")
```
-### 3. Register in apps.py
+Functions in `djarea_clients.py` are discovered automatically — same convention as `models.py`.
-```python
-class MyAppConfig(AppConfig):
- name = "myapp"
+### 3. Generate TypeScript
- def ready(self):
- import myapp.djarea_clients # noqa: F401
-```
-
-### 4. Generate TypeScript
-
-```bash
-# django.config.mjs
+```javascript
+// django.config.mjs
export default {
source: {
django: {
@@ -94,23 +95,17 @@ export default {
npx djarea-generate
```
-This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
-
-### 5. Use in React
+### 4. Use in React
```tsx
-// layout.tsx
-import { DjangoContext } from '@/api'
+import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'
+// layout.tsx — one provider, handles everything
export default function Layout({ children }) {
return {children}
}
-```
-```tsx
// page.tsx
-import { useEcho, useCurrentUser, DjangoError } from '@/api'
-
function MyComponent() {
const user = useCurrentUser()
const echo = useEcho()
@@ -121,89 +116,78 @@ function MyComponent() {
console.log(result.message) // typed
} catch (e) {
if (e instanceof DjangoError) {
- console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
+ console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
+ e.getFieldErrors('email') // field-level errors
}
}
}
}
```
-## Features
+## Shapes
-| Backend | Frontend (generated) | Transport |
-|---------|---------------------|-----------|
-| `@client` | `useXxx()` | HTTP |
-| `@client(context='global')` | `useXxx()` + SSR hydration | HTTP |
-| `@client(context='local')` | `useXxx()` with params | HTTP |
-| `@client(websocket=True)` | `useXxx()` | WebSocket RPC |
-| `@client(auth=True\|'staff'\|callable)` | Auth errors as `DjangoError` | HTTP |
-| `DjareaFormMixin` | `useXxxForm()` + Zod validation | HTTP |
-| `ReactChannel` | `useXxxChannel()` | WebSocket |
-| `@compose(...)` | Combined providers | varies |
+Shapes are Djarea's projection system. A Shape defines exactly which fields to select from the database, validated through Pydantic and projected through django-readers. Different views get different Shapes — same model, different queries.
-## Architecture
+```python
+# Full detail page — joins books with chapters
+class AuthorDetailShape(Shape[Author]):
+ id: int | None = None
+ name: str
+ bio: str
+ books: list[BookShape] = []
-```
-React app
- └─ ← generated provider (includes ChannelProvider)
- ├─ useCurrentUser() ← generated context hook (SSR-hydrated)
- ├─ useEcho() ← generated function hook
- ├─ useContactForm() ← generated form hook (Zod + server validation)
- └─ useChatChannel() ← generated channel hook (WebSocket)
- │
- ├─ HTTP: POST /api/djarea/call/ { fn: "echo", args: { text: "hi" } }
- └─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
- │
- Django executor
- ├─ Pydantic input validation
- ├─ Auth check (session, JWT, or custom)
- ├─ Function execution
- └─ Pydantic output serialization
+# Dropdown menu — two columns, no joins
+class FlatAuthorShape(Shape[Author]):
+ id: int | None = None
+ name: str
```
-The generated `DjangoContext` is the **only provider** needed. It wraps `DjareaProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
+```python
+# Detail page: SELECT id, name, bio + prefetch books
+authors = AuthorDetailShape.query()
-## Code Generation
-
-`npx djarea-generate` reads Django schemas (no running server needed) and produces:
-
-| File | Contents |
-|------|----------|
-| `generated.djarea.ts` | Pydantic model types (via openapi-typescript) |
-| `generated.django.tsx` | `DjangoContext` provider + all typed hooks |
-| `generated.django.server.ts` | SSR hydration helper (`getDjangoHydration`) |
-| `generated.forms.ts` | Form hooks with Zod schemas (`useContactForm`, etc.) |
-| `generated.channels.ts` | Channel message types |
-| `generated.channels.hooks.tsx` | Channel hooks (`useChatChannel`, etc.) |
-| `index.ts` | Consolidated re-exports |
-
-## Error Handling
-
-All errors from server functions are thrown as `DjangoError`:
-
-```tsx
-try {
- await echo({ text: 'hello' })
-} catch (e) {
- if (e instanceof DjangoError) {
- e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | ...
- e.message // Human-readable message
- e.details // Field-level validation errors, etc.
- e.isAuthError()
- e.isValidationError()
- e.getFieldErrors('email')
- }
-}
+# Dropdown: SELECT id, name. That's it.
+authors = FlatAuthorShape.query()
```
-Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
+Shapes also support diffing. When the frontend sends state back, the diff system compares incoming data against the current database state and tells you exactly what changed:
+
+```python
+@client
+def update_articles(request, articles: list[ArticleShape]) -> dict:
+ for article, diff in ArticleShape.diff_many(articles):
+ if diff.is_new:
+ create_article(article)
+ elif diff.changed:
+ update_fields(article, diff.changed)
+ for tag in diff.tags.created:
+ add_tag(article, tag)
+ for tag_id in diff.tags.deleted:
+ remove_tag(article, tag_id)
+ return {"ok": True}
+```
+
+One query fetches all current state. The diff is per-field and per-nested-relation. Your service code only touches what actually changed.
+
+## The `@client` decorator
+
+The decorator controls transport, caching, auth, and SSR behavior:
+
+| Decorator | React hook | What it does |
+|-----------|-----------|--------------|
+| `@client` | `useEcho()` | HTTP call, returns typed result |
+| `@client(context='global')` | `useCurrentUser()` | Fetched once, cached in context, SSR-hydrated |
+| `@client(context='local')` | `useArticle({ id })` | Cached per unique params |
+| `@client(websocket=True)` | `useSearch()` | Runs over WebSocket instead of HTTP |
+| `@client(auth=True)` | — | Requires authentication |
+| `@client(auth='staff')` | — | Requires staff status |
+| `@client(auth=my_check)` | — | Custom auth callable |
## Forms
-Django forms get typed React hooks with client-side Zod validation:
+Django forms become typed React hooks with client-side Zod validation:
```python
-# Django
class ContactForm(DjareaFormMixin, forms.Form):
djarea = DjareaFormMeta(
name="contact",
@@ -221,22 +205,22 @@ class ContactForm(DjareaFormMixin, forms.Form):
```
```tsx
-// React (generated)
const form = useContactForm()
-form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
+form.schema // field metadata, title, submit label
form.data // { name: '', email: '', message: '' }
form.set('email', v) // typed setter
form.errors // field-level errors (Zod + server)
form.submit() // → { success: true, data: { sent: true } }
```
+Zod schemas are generated from the Django form definition. Validation runs client-side first, server-side second. No duplicated validation logic.
+
## Channels
WebSocket channels with typed messages:
```python
-# Django
class ChatChannel(ReactChannel):
class Params(BaseModel):
room: str
@@ -257,7 +241,6 @@ class ChatChannel(ReactChannel):
```
```tsx
-// React (generated)
const chat = useChatChannel({ room: 'general' })
chat.status // 'connecting' | 'connected' | 'disconnected'
@@ -265,32 +248,98 @@ chat.messages // ChatDjangoMessage[]
chat.send({ text: 'hello' })
```
+## Architecture
+
+```
+React app
+ └─ ← generated provider (session, CSRF, WebSocket)
+ ├─ useCurrentUser() ← context hook (SSR-hydrated)
+ ├─ useEcho() ← function hook
+ ├─ useContactForm() ← form hook (Zod + server validation)
+ └─ useChatChannel() ← channel hook (WebSocket)
+ │
+ ├─ HTTP: POST /api/djarea/call/ { fn: "echo", args: { text: "hi" } }
+ └─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
+ │
+ Django executor
+ ├─ Pydantic input validation
+ ├─ Auth check
+ ├─ Function execution
+ └─ Pydantic output serialization
+```
+
+All transport goes through a single endpoint. The generated `DjangoContext` is the only provider. It handles session init, CSRF, context auto-fetching, and WebSocket connection.
+
+## Code generation
+
+`npx djarea-generate` reads Django schemas at build time (no running server) and produces:
+
+| File | Contents |
+|------|----------|
+| `generated.djarea.ts` | Pydantic model types |
+| `generated.django.tsx` | `DjangoContext` provider + typed hooks |
+| `generated.django.server.ts` | SSR hydration helper |
+| `generated.forms.ts` | Form hooks with Zod schemas |
+| `generated.channels.ts` | Channel message types |
+| `generated.channels.hooks.tsx` | Channel hooks |
+| `index.ts` | Re-exports |
+
+## Error handling
+
+All errors from server functions throw as `DjangoError`:
+
+```tsx
+if (e instanceof DjangoError) {
+ e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | ...
+ e.message // human-readable
+ e.details // field-level validation errors
+ e.isAuthError()
+ e.isValidationError()
+ e.getFieldErrors('email')
+}
+```
+
+## Why RPC instead of REST
+
+REST exposes your database tables as CRUD endpoints and pushes business logic to the frontend. "Submit an application" becomes PATCH one resource, POST another, PUT a third — choreographed by client code.
+
+Djarea keeps business logic on the server. You write functions that do things. The frontend calls them. The server knows what "submit" means. The client doesn't need to.
+
+If you delete the frontend of a REST app, your backend is a database. If you delete the frontend of a Djarea app, your backend still has your entire application logic.
+
+## Packages
+
+| Package | Install |
+|---------|---------|
+| `djarea` (Python) | `pip install djarea` |
+| `@rythazhur/djarea` (TypeScript) | `npm install @rythazhur/djarea` |
+
+For WebSocket support: `pip install "djarea[channels]"`
+
## Testing
```bash
-# Django unit tests
-cd django && uv sync --extra dev --extra channels && uv run pytest
+# Django
+cd django && uv run pytest
-# React unit tests
+# React
cd react && npm test
-# E2E integration tests (real browser, real backend)
+# E2E (Playwright, real browser + real backend)
docker compose -f docker-compose.test.yml up -d
-cd e2e/harness && npm install && npx djarea-generate && npx vite --port 5174 &
-npx playwright test
+cd e2e/harness && npx djarea-generate && npx playwright test
-# All at once
+# Everything
make test-all
```
-## Project Structure
+## Project structure
```
djarea/
- django/ Python package (djarea)
- react/ TypeScript package (@rythazhur/djarea)
- example/ Integration test backend (Docker)
- desktop/ PyWebView desktop test app
- e2e/ Playwright E2E tests + React harness
+ django/ Python package
+ react/ TypeScript package
+ example/ Integration test backend
+ e2e/ Playwright E2E tests
Makefile Test orchestration
-```
+```
\ No newline at end of file