Files
mizan/MIZAN.md
Ryth Azhur c866142770 Rename djarea to mizan and fix React casing conventions
Rename the package from djarea to mizan across the entire codebase —
Python package, React library, generators, tests, and examples. Fix
JSX/hook casing (MizanProvider, useMizan, etc.) that broke when the
original PascalCase names were lowercased during the rename.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00

16 KiB

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 mizan 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.

@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

# 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

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

// 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:

{
    "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)

@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.

// 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

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:

@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

// 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:

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.

# 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): ...
// 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.