Add Mizan architecture plan
Named contexts, param elevation, affects-based invalidation, ReactContext classes for read/write coupling. This is the evolution roadmap from Djarea to Mizan. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
464
MIZAN.md
Normal file
464
MIZAN.md
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
# MIZAN — Named Contexts & Mutation Architecture
|
||||||
|
|
||||||
|
## For Claude Code
|
||||||
|
|
||||||
|
This plan was written by Ryth's Claude.ai session after an extended design conversation
|
||||||
|
reviewing the full codebase, the original @compose discussion from January 2025, and
|
||||||
|
several rounds of architectural refinement. Treat this as the spec.
|
||||||
|
|
||||||
|
The framework formerly called Djarea is now called **MIZAN**. Package names, imports,
|
||||||
|
and references should be updated accordingly. The internal codegen engine is called
|
||||||
|
**Maison** — it lives inside Mizan and does not need its own public surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
MIZAN has three tiers of developer-facing API:
|
||||||
|
|
||||||
|
```
|
||||||
|
@client → a function I call from React
|
||||||
|
@client(context='global') → read-only data, fetched once, everywhere
|
||||||
|
@client(context='<name>') → read-only data, fetched when provider mounts
|
||||||
|
@client(affects='<name>') → a mutation that triggers context refresh
|
||||||
|
@client(affects=specific_function) → a mutation that triggers a specific function's refresh
|
||||||
|
ReactContext('<name>') with send → read-only data, class form
|
||||||
|
ReactContext('<name>') with send+receive → read/write data, explicit mutation logic
|
||||||
|
```
|
||||||
|
|
||||||
|
Decorators are the infantry — single-purpose, lightweight, composable.
|
||||||
|
Classes are the battleship — multi-surface, explicit, for tight read/write coupling.
|
||||||
|
|
||||||
|
The developer picks the tier that matches their complexity. Most apps never need
|
||||||
|
the class form. `@client` + `affects` covers 95% of cases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Named Contexts (replacing context='local' and @compose)
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
Any string passed to `context=` becomes a named context. Functions and classes sharing
|
||||||
|
the same context string are automatically grouped into one provider, one fetch request,
|
||||||
|
and one set of hooks.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@client(context='user')
|
||||||
|
def user_profile(request, user_id: int) -> UserProfileShape:
|
||||||
|
return UserProfileShape.query(lambda qs: qs.filter(user__pk=user_id))[0]
|
||||||
|
|
||||||
|
@client(context='user')
|
||||||
|
def user_orders(request, user_id: int) -> list[OrderShape]:
|
||||||
|
return OrderShape.query(lambda qs: qs.filter(user__pk=user_id))
|
||||||
|
|
||||||
|
@client(context='user')
|
||||||
|
def user_friends(request, user_id: int) -> list[FlatUserShape]:
|
||||||
|
return FlatUserShape.query(lambda qs: qs.filter(friends__pk=user_id))
|
||||||
|
```
|
||||||
|
|
||||||
|
Three functions, one context name, one generated provider.
|
||||||
|
|
||||||
|
### Context type resolution
|
||||||
|
- `context='global'` → special case. Collected into `<MizanContext/>` (the root provider).
|
||||||
|
Fetched once at app root. SSR-hydrated. No params.
|
||||||
|
- `context='<any other string>'` → generates `<XxxContext/>` provider. Fetched when mounted.
|
||||||
|
Accepts params as props.
|
||||||
|
- No `context` (default) → not a context. Just a callable function.
|
||||||
|
|
||||||
|
### What codegen produces for `context='user'`
|
||||||
|
- `<UserContext user_id={...}>` — provider component
|
||||||
|
- `useUserProfile()` — typed hook, returns `UserProfileShape`
|
||||||
|
- `useUserOrders()` — typed hook, returns `OrderShape[]`
|
||||||
|
- `useUserFriends()` — typed hook, returns `FlatUserShape[]`
|
||||||
|
|
||||||
|
### `context='global'` is just a named context
|
||||||
|
There is no separate mechanism for global contexts. `'global'` is a reserved context
|
||||||
|
name whose provider is automatically included in the root `<MizanContext/>`. All other
|
||||||
|
named contexts generate standalone providers the developer mounts themselves.
|
||||||
|
|
||||||
|
### Registration-time validation
|
||||||
|
- Duplicate function/class names within the same context → error
|
||||||
|
- Mixed WebSocket transport within a context (some `websocket=True`, some not) → error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Param Elevation
|
||||||
|
|
||||||
|
Functions sharing a context name have their parameters analyzed at codegen time.
|
||||||
|
Shared params elevate to required provider props. Non-shared params elevate to
|
||||||
|
optional provider props. Per-function overrides are available via `specify`.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
# All three take user_id
|
||||||
|
# Only orders and friends take page_size, page_index
|
||||||
|
def user_profile(request, user_id: int) -> UserProfileShape: ...
|
||||||
|
def user_orders(request, user_id: int, page_size: int, page_index: int) -> ...: ...
|
||||||
|
def user_friends(request, user_id: int, page_size: int, page_index: int) -> ...: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generated provider interface
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface UserContextProps {
|
||||||
|
children: ReactNode
|
||||||
|
user_id: number // required — all functions need it
|
||||||
|
page_size?: number // optional — orders + friends only
|
||||||
|
page_index?: number // optional — orders + friends only
|
||||||
|
specify?: { // per-function overrides
|
||||||
|
user_orders?: { page_size?: number; page_index?: number }
|
||||||
|
user_friends?: { page_size?: number; page_index?: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolution order (frontend runtime)
|
||||||
|
For each function in the context:
|
||||||
|
1. Start with elevated props from the provider component
|
||||||
|
2. Override with values from `specify[function_name]` if present
|
||||||
|
3. Runtime error if any required param for that function is still missing
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Simple — shared params cover everything
|
||||||
|
<UserContext user_id={user.id} page_size={20} page_index={0}>
|
||||||
|
<UserPage />
|
||||||
|
</UserContext>
|
||||||
|
|
||||||
|
// Override — different pagination per function
|
||||||
|
<UserContext user_id={user.id} specify={{
|
||||||
|
user_orders: { page_size: 20, page_index: ordersPage },
|
||||||
|
user_friends: { page_size: 10, page_index: friendsPage }
|
||||||
|
}}>
|
||||||
|
<UserPage />
|
||||||
|
</UserContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Server Bundling & Transport
|
||||||
|
|
||||||
|
### Context fetch
|
||||||
|
All functions in a named context are fetched in a single GET request.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/mizan/ctx/user/?user_id=123&page_size=20&page_index=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"data": {
|
||||||
|
"user_profile": { ... },
|
||||||
|
"user_orders": [ ... ],
|
||||||
|
"user_friends": [ ... ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server executes each function, bundles results, returns one response.
|
||||||
|
The frontend splits the response into individual hook states.
|
||||||
|
|
||||||
|
Context functions use GET because they are reads. This makes them CDN-cacheable,
|
||||||
|
edge-cacheable, and compatible with standard HTTP caching headers.
|
||||||
|
|
||||||
|
### Global context fetch
|
||||||
|
```
|
||||||
|
GET /api/mizan/ctx/global/
|
||||||
|
```
|
||||||
|
No params. Fetched once. SSR-hydrated.
|
||||||
|
|
||||||
|
### Mutation calls
|
||||||
|
Non-context `@client` functions (including those with `affects`) use the existing
|
||||||
|
POST endpoint:
|
||||||
|
```
|
||||||
|
POST /api/mizan/call/
|
||||||
|
{ "fn": "profile_edit", "args": { "name": "new name" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache key
|
||||||
|
The cache identity for a context is: context name + shared elevated params.
|
||||||
|
`user_id=123` is one cache entry. Per-function overrides via `specify` are
|
||||||
|
part of the request but do not change the cache identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mutation Invalidation with `affects`
|
||||||
|
|
||||||
|
This is the key feature. Mutations declare which contexts they invalidate.
|
||||||
|
The generated client code handles the refetch automatically.
|
||||||
|
|
||||||
|
### Declaration (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@client(context='user')
|
||||||
|
def user_profile(request, user_id: int) -> UserProfileShape:
|
||||||
|
return UserProfileShape.query(lambda qs: qs.filter(user__pk=user_id))[0]
|
||||||
|
|
||||||
|
# Mutation that invalidates the entire user context
|
||||||
|
@client(affects='user')
|
||||||
|
def profile_edit(request, name: str, email: str) -> dict:
|
||||||
|
User.objects.filter(pk=request.user.pk).update(name=name, email=email)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# Mutation that invalidates only user_profile within the user context
|
||||||
|
@client(affects=user_profile)
|
||||||
|
def update_avatar(request, avatar_url: str) -> dict:
|
||||||
|
User.objects.filter(pk=request.user.pk).update(avatar=avatar_url)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# Mutation that invalidates specific functions
|
||||||
|
@client(affects=[user_profile, user_orders])
|
||||||
|
def change_subscription(request, plan: str) -> dict:
|
||||||
|
update_plan(request.user, plan)
|
||||||
|
return {"ok": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `affects` options
|
||||||
|
- `affects='context_name'` — refetch the entire named context (all functions in it)
|
||||||
|
- `affects=function_ref` — refetch only that specific function within its context
|
||||||
|
- `affects=[fn1, fn2]` — refetch specific functions (can span multiple contexts)
|
||||||
|
|
||||||
|
### What codegen produces for a mutation with `affects`
|
||||||
|
|
||||||
|
The generated hook bakes the invalidation relationship into the client code.
|
||||||
|
The developer never writes cache invalidation logic.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Generated
|
||||||
|
export function useProfileEdit() {
|
||||||
|
const mizan = useMizan()
|
||||||
|
return async (input: { name: string; email: string }) => {
|
||||||
|
const result = await mizan.call('profile_edit', input)
|
||||||
|
// Auto-invalidate: refetch entire 'user' context
|
||||||
|
await mizan.invalidateContext('user')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalidation behavior
|
||||||
|
- Mutation fires via POST
|
||||||
|
- On success, the framework automatically refetches the affected context(s)
|
||||||
|
- If the affected context is not currently mounted, nothing happens
|
||||||
|
(no wasted requests for data nobody is looking at)
|
||||||
|
- On mutation failure, no invalidation occurs
|
||||||
|
|
||||||
|
### Parallel to React Query
|
||||||
|
This is the same model as TanStack Query (React Query), but the query keys and
|
||||||
|
invalidation relationships are declared server-side in Python and generated into
|
||||||
|
the client. The developer never manages cache keys, never calls `invalidateQueries`,
|
||||||
|
never wires up `onSuccess` callbacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ReactContext Classes (the read/write surface)
|
||||||
|
|
||||||
|
For cases where mutation logic is tightly coupled to the read shape — where the
|
||||||
|
frontend sends modified state back and the server diffs it — `ReactContext` classes
|
||||||
|
provide explicit `send` and `receive` methods.
|
||||||
|
|
||||||
|
This is the heavy weapon. Most apps don't need it. `@client` + `affects` covers
|
||||||
|
the common case. Use `ReactContext` when:
|
||||||
|
- The frontend edits data in place and commits changes (form-like behavior)
|
||||||
|
- The mutation needs to diff against current state (Shape diffing)
|
||||||
|
- The read and write logic are tightly coupled and belong in one place
|
||||||
|
|
||||||
|
### Definition
|
||||||
|
|
||||||
|
```python
|
||||||
|
class UserProfile(ReactContext('user')):
|
||||||
|
def send(self, request, user_id: int) -> UserProfileShape:
|
||||||
|
return UserProfileShape.query(lambda qs: qs.filter(user__pk=user_id))[0]
|
||||||
|
|
||||||
|
def receive(self, request, data: UserProfileShape):
|
||||||
|
if not request.user.is_staff and request.user.pk != data.id:
|
||||||
|
raise PermissionError("Cannot edit another user's profile")
|
||||||
|
|
||||||
|
diff = data.diff()
|
||||||
|
|
||||||
|
if not diff.changed:
|
||||||
|
return {"status": "no_changes"}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
User.objects.filter(pk=data.id).update(**diff.changed)
|
||||||
|
|
||||||
|
if 'email' in diff.changed:
|
||||||
|
transaction.on_commit(lambda: send_verification_email(data.email))
|
||||||
|
|
||||||
|
return {"status": "updated", "changed": list(diff.changed.keys())}
|
||||||
|
```
|
||||||
|
|
||||||
|
`send` — what goes out. The read surface. Same as a `@client(context=...)` function.
|
||||||
|
`receive` — what comes back. The write surface. The developer owns all logic: diffing,
|
||||||
|
validation, auth, transactions, side effects.
|
||||||
|
|
||||||
|
### Mixing with decorated functions
|
||||||
|
ReactContext classes and @client functions can share a context name:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@client(context='user')
|
||||||
|
def user_friends(request, user_id: int) -> list[FlatUserShape]:
|
||||||
|
return FlatUserShape.query(lambda qs: qs.filter(friends__pk=user_id))
|
||||||
|
|
||||||
|
class UserProfile(ReactContext('user')):
|
||||||
|
def send(self, request, user_id: int) -> UserProfileShape: ...
|
||||||
|
def receive(self, request, data: UserProfileShape): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Both live in the `'user'` context. The generated `<UserContext/>` includes both.
|
||||||
|
Only `UserProfile` has a commit surface because only it defines `receive`.
|
||||||
|
|
||||||
|
### What codegen produces
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Read hook (from send)
|
||||||
|
const profile = useUserProfile()
|
||||||
|
|
||||||
|
// Commit function (from receive)
|
||||||
|
const commitProfile = useCommitUserProfile()
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const handleSave = async () => {
|
||||||
|
const result = await commitProfile(modifiedProfile)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/mizan/ctx/user/commit/
|
||||||
|
{
|
||||||
|
"user_profile": { ...modified shape data... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server routes `user_profile` data to `UserProfile.receive()`.
|
||||||
|
Multiple writable members can be committed in one request.
|
||||||
|
|
||||||
|
### Invalidation after commit
|
||||||
|
After a successful commit, the context automatically refetches all `send` methods
|
||||||
|
(and all @client context functions in the same named context). The frontend state
|
||||||
|
is guaranteed to reflect current DB state.
|
||||||
|
|
||||||
|
If the developer wants to avoid the extra round trip, they can return a Shape instance
|
||||||
|
from `receive` matching the `send` return type. The framework detects this and uses
|
||||||
|
it as the new state instead of refetching:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def receive(self, request, data: UserProfileShape):
|
||||||
|
User.objects.filter(pk=data.id).update(**data.dict(exclude={'id'}))
|
||||||
|
# Return fresh state — framework skips refetch for this function
|
||||||
|
return UserProfileShape.query(lambda qs: qs.filter(pk=data.id))[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
### What the developer owns in receive()
|
||||||
|
Everything. The framework provides the Shape data as a typed Pydantic object.
|
||||||
|
The developer decides:
|
||||||
|
- Whether to diff (data.diff() or Shape.diff_many())
|
||||||
|
- How deep to diff
|
||||||
|
- What auth checks to perform
|
||||||
|
- What validation to run
|
||||||
|
- Whether to wrap in a transaction
|
||||||
|
- What side effects to trigger
|
||||||
|
- What to return
|
||||||
|
|
||||||
|
Mutation is business logic, not automation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Discovery and Registration
|
||||||
|
|
||||||
|
### @client functions
|
||||||
|
Discovered via `clients.py` convention (DjangoAppVisitor), same as current.
|
||||||
|
|
||||||
|
### ReactContext classes
|
||||||
|
Same discovery. Classes inheriting from `ReactContext` found in `clients.py` are
|
||||||
|
registered automatically. The context name string and presence/absence of `receive`
|
||||||
|
are detected at registration time.
|
||||||
|
|
||||||
|
### Registration-time validation
|
||||||
|
- Duplicate names within same context → error
|
||||||
|
- Mixed WebSocket transport within context → error
|
||||||
|
- `receive` defined without `send` → error
|
||||||
|
- `affects` referencing a non-existent context name or function → error (or warning)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. What to Remove / Deprecate
|
||||||
|
|
||||||
|
- `context='local'` → replaced by any non-'global' context string
|
||||||
|
- `@compose` decorator → replaced by shared context names
|
||||||
|
- `ComposedContext` class → remove from public API
|
||||||
|
- `on_server` flag → default behavior (contexts always bundled)
|
||||||
|
- `share` prop pattern → replaced by param elevation + `specify`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementation Order
|
||||||
|
|
||||||
|
### Phase 1: Named contexts (core feature)
|
||||||
|
1. Accept any string for `context=` (not just 'global'/'local')
|
||||||
|
2. Group functions by context name in the registry
|
||||||
|
3. Add context bundling endpoint: `GET /api/mizan/ctx/<name>/`
|
||||||
|
4. Update codegen to produce named providers with param elevation
|
||||||
|
5. Update codegen to produce `specify` prop handling
|
||||||
|
6. Make `context='global'` use the same mechanism, just auto-mounted
|
||||||
|
|
||||||
|
### Phase 2: affects invalidation
|
||||||
|
1. Add `affects` parameter to `@client` decorator
|
||||||
|
2. Accept string (context name), function reference, or list
|
||||||
|
3. Store affects metadata in the function's `_meta` dict
|
||||||
|
4. Export affects relationships in the schema
|
||||||
|
5. Update codegen: mutation hooks auto-invalidate after success
|
||||||
|
6. Frontend: invalidation checks if affected context is mounted before refetching
|
||||||
|
|
||||||
|
### Phase 3: ReactContext classes
|
||||||
|
1. Implement `ReactContext` base class with metaclass magic for the string arg
|
||||||
|
2. `send` method registered as a context function (same as @client with context)
|
||||||
|
3. `receive` method registered as a commit handler
|
||||||
|
4. Commit endpoint: `POST /api/mizan/ctx/<name>/commit/`
|
||||||
|
5. Update codegen: produce commit hooks for classes with `receive`
|
||||||
|
6. Auto-refetch after commit, with optional fresh-data-from-receive optimization
|
||||||
|
|
||||||
|
### Phase 4: Cleanup
|
||||||
|
1. Remove `@compose` from public API and docs
|
||||||
|
2. Remove `context='local'` (accept for backwards compat with deprecation warning)
|
||||||
|
3. Update README and all examples
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. The Developer's Mental Model
|
||||||
|
|
||||||
|
Write functions. Name your contexts. Declare what affects what.
|
||||||
|
The framework generates the client, handles the caching, and runs the invalidation.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# I read data
|
||||||
|
@client(context='user')
|
||||||
|
def user_profile(request, user_id: int) -> UserProfileShape: ...
|
||||||
|
|
||||||
|
# I mutate data and declare what it affects
|
||||||
|
@client(affects='user')
|
||||||
|
def edit_profile(request, name: str) -> dict: ...
|
||||||
|
|
||||||
|
# I need tight read/write coupling (rare, powerful)
|
||||||
|
class UserProfile(ReactContext('user')):
|
||||||
|
def send(self, request, user_id: int) -> UserProfileShape: ...
|
||||||
|
def receive(self, request, data: UserProfileShape): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// I use the data
|
||||||
|
const profile = useUserProfile()
|
||||||
|
|
||||||
|
// I call the mutation — invalidation is automatic
|
||||||
|
const editProfile = useEditProfile()
|
||||||
|
await editProfile({ name: 'new name' })
|
||||||
|
// useUserProfile() updates automatically. I wrote zero invalidation code.
|
||||||
|
```
|
||||||
|
|
||||||
|
No REST. No CRUD. No cache keys. No manual invalidation.
|
||||||
|
The decorator is the declaration. The framework is the execution.
|
||||||
Reference in New Issue
Block a user