diff --git a/MIZAN.md b/MIZAN.md new file mode 100644 index 0000000..e467de0 --- /dev/null +++ b/MIZAN.md @@ -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='') → read-only data, fetched when provider mounts +@client(affects='') → a mutation that triggers context refresh +@client(affects=specific_function) → a mutation that triggers a specific function's refresh +ReactContext('') with send → read-only data, class form +ReactContext('') 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 `` (the root provider). + Fetched once at app root. SSR-hydrated. No params. +- `context=''` → generates `` provider. Fetched when mounted. + Accepts params as props. +- No `context` (default) → not a context. Just a callable function. + +### What codegen produces for `context='user'` +- `` — 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 ``. 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 + + + + +// Override — different pagination per function + + + +``` + +--- + +## 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 `` 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//` +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//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.