diff --git a/CLAUDE.md b/CLAUDE.md index a66f072..228255c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,15 +17,24 @@ backends/ server protocol adapters mizan-django/ Django adapter mizan-fastapi/ FastAPI adapter (RPC + context + invalidation; AFI-common scope) mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic) -frontends/ client kernel + per-framework adapters + mizan-rust-axum/ Rust/Axum adapter (server-side substrate; three-way parity) + mizan-tauri/ Tauri-as-Mizan-backend substrate +frontends/ client kernel + per-framework adapters + transports mizan-base/ framework-agnostic kernel; owns data, status, error; adapters subscribe mizan-react/ React contexts + hooks over the kernel - mizan-vue/ Vue composables over the kernel - mizan-svelte/ Svelte stores/runes over the kernel + mizan-vue/ Vue composables over the kernel (codegen target; runtime package unimplemented) + mizan-svelte/ Svelte stores over the kernel (codegen target; runtime package unimplemented) + mizan-rust/ Rust kernel (PyO3 bridge; consumed by the Rust codegen's python target) + mizan-tauri-transport/ Tauri IPC transport for the kernel + mizan-webview-transport/ VSCode-webview transport for the kernel + mizan-webview-channels/ webview channel transport cores/ shared language-level primitives mizan-python/ @client decorator, registry, MWT, HMAC cache keys; consumed by both Python backends + mizan-rust/ shared Rust primitives (IR, KDL, registry, graph-check) + mizan-rust-macros/ proc-macros for the Rust backend/kernel protocol/ protocol-level tooling - mizan-generate/ codegen — fetches schema from any backend, emits typed React/Vue/Svelte client + mizan-codegen/ the codegen — a Rust binary; reads KDL IR, emits typed clients per target + mizan-generate/ thin npm launcher around the compiled mizan-codegen binary workers/ runtime workers / bridges mizan-ssr/ Bun subprocess used by the Django template backend ``` @@ -446,22 +455,18 @@ urlpatterns = [ ## Codegen — Current State -The codegen is `protocol/mizan-generate/` — framework-agnostic, two-stage. Stage 1 emits the protocol layer (`callXxx` for mutations, `fetchXxx` for context bundles, types). Stage 2 emits per-framework hooks/composables/stores that subscribe to the `mizan-base` kernel. +The codegen is a **Rust binary**, `protocol/mizan-codegen/` (crate `mizan-codegen`). `protocol/mizan-generate/` is a thin npm launcher (`bin/launcher.mjs`) that shells out to the compiled binary. The IR is **KDL** — each backend emits KDL describing its functions/contexts; the binary reads it (`src/fetch.rs`, `src/ir.rs`) and emits per-target output from Askama templates (`templates/`, dispatched in `src/emit/`). -**What's in place:** +Two layers, same as before: a framework-agnostic protocol layer (`callXxx` for mutations, `fetchXxx` for context bundles, types) and a per-framework adapter layer that subscribes to the `mizan-base` kernel. -- Function hooks (`useEcho`, `useUserProfile`, etc.) in the React adapter, subscribing to kernel state via `useSyncExternalStore` -- Context hooks for named contexts and `global` -- Channel hooks for WebSocket transport -- Vue and Svelte equivalents (Stage 2 templates compile, but no live-backend example exercises them — see `ISSUES.md` A4) +**Targets** (`src/emit/`, each byte-checked by a `*_parity.rs` test): -**What's not yet emitted (the wrapper layer):** +- `react` — function/context hooks over `useSyncExternalStore`, plus the full wrapper layer: the `MizanContext` root provider (calls `configure()`, mounts the global context), `useMizan()` imperative escape hatch, and `useMutation`-backed hooks exposing `{ mutate, isPending, error }`. +- `vue`, `svelte` — composables / `readable` stores. Byte-parity-tested, but no runtime adapter package or live-backend example exercises them yet (the `mizan-vue`/`mizan-svelte` packages are unimplemented stubs). +- `channels` — WebSocket transport hooks. +- `stage1` — the framework-agnostic protocol files. +- `python`, `rust` — typed clients for the Python (PyO3) and Rust frontends. -- `` provider component for React (calls `configure()` and mounts the kernel into the component tree) -- `useMizan()` hook for accessing the kernel from React -- Framework-named error class (e.g. `DjangoError`) wrapping `MizanError` from the kernel -- Vue and Svelte equivalents +The pre-kernel `MizanProvider` in `mizan-react/src/context.tsx` (~750 lines) still ships and is imported by the desktop example; it coexists with the generated `MizanContext`. Forms (`mizan-react/src/forms.ts`) are hand-written and consume the pre-kernel provider — a form codegen target wired to `mizanCall` is still owed. See `ISSUES.md`. -The legacy `MizanProvider` in `mizan-react/src/context.tsx` (~750 lines) and the `forms.ts` consumer (~1163 lines) still depend on the pre-kernel API. They're tracked as `ISSUES.md` A1 and A3. Removing them is gated on the wrapper layer above being emitted. - -The SSR pipeline is independent of the codegen — it renders whatever components are registered in the Bun worker. +The SSR pipeline is independent of the codegen — the Bun worker resolves a component by **file path** (`import(file)` + `renderToString`), not via a registry. diff --git a/ISSUES.md b/ISSUES.md index e3a0200..2571696 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -1,178 +1,23 @@ # Mizan — Known Issues -Identified by domain expert review (Cloudflare, Serverless, Vercel, React Query, Django, Laravel, Vue/Svelte). +Status board against the current codebase: Rust codegen (`protocol/mizan-codegen`), +KDL IR, kernel-owned frontend state (`@mizan/base`). Issues that the earlier +expert-review board filed against the deleted JavaScript codegen and the +pre-kernel `mizan-react` provider have been removed — they audited files that +no longer exist. -## Fixed +## Open -- ~~C1~~ Scoped cache purge now passes user_id -- ~~C2~~ initSession retries 3x, resets on failure -- ~~C3~~ SSR backend injects `__MIZAN_SSR_DATA__` script tag -- ~~C4~~ SSR bridge uses _write_lock for stdin -- ~~C5~~ SSR bridge registers atexit handler -- ~~C7~~ View-path mutations now purge origin cache -- ~~H1~~ pendingScoped is Array, not Map (no overwrite) -- ~~H2~~ stableKey() sorts JSON keys (order-independent) -- ~~H3~~ mizanFetch retries 2x on 5xx/network errors -- ~~H4~~ Named contexts skip refetch if SSR data exists -- ~~H6~~ refreshContext uses GET /ctx/ not POST /call/ -- ~~H10~~ _meta always fresh dict -- ~~H11~~ Python normalizes True→"true" for cross-language HMAC -- ~~H13~~ isValid checks all required fields are touched -- ~~H14~~ `@client(merge=...)` primitive — kernel splices return value into cached context, no refetch -- ~~H15~~ Schema export handles `BaseModel | None` return types -- ~~H16~~ Generated `react.tsx` imports per-function `*Output` types -- ~~M11~~ execute_function return type includes HttpResponseBase -- ~~M18~~ registerContext cleanup uses ?. (no crash) -- ~~M19~~ `list[BaseModel]` returns reach the wire as bare arrays (RootModel-based rename, no `{result: ...}` wrap) -- ~~M20~~ `initSession()` gated on `configure({session: bool})` (Django default-on, FastAPI opt-out) +- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification. +- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom. +- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed. +- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it. +- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: purge atomicity, cross-language stringification, per-param sub-index cleanup, thundering-herd protection, `cache_get`/`cache_put` argument inconsistency, RedisCache test coverage. +- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`. -## Remaining Critical +## Resolved this pass -### C6. No loading/error/stale states in runtime -**File:** `mizan-base/src/index.ts` -The kernel stores only `{params, refetch}`. No `data`, `status`, `error`. Every adapter reinvents loading tracking. Blocks stale-while-revalidate. - -## Remaining High - -### H5. Mutation hooks expose no loading/error state -**File:** `protocol/mizan-generate/generator/lib/adapters/react.mjs` -Returns bare `useCallback`. No `isPending`, `error`, `isSuccess`. - -### H7. Redis SCAN blocks request path at scale -**File:** `mizan-django/src/mizan/cache/backend.py` -Synchronous SCAN at 1M keys: multi-second blocking. - -### H8. Svelte codegen uses Svelte 4 stores -**File:** `protocol/mizan-generate/generator/lib/adapters/svelte.mjs` -Should use Svelte 5 `$state`/`$derived` runes. - -### H9. Svelte destroy() not auto-called -**File:** `protocol/mizan-generate/generator/lib/adapters/svelte.mjs` -Memory leak if user forgets `onDestroy`. - -### H12. Forms triggerValidation captures stale data -**File:** `mizan-react/src/forms.ts` -Debounced validation uses stale closure data. - -## Remaining Medium - -### M1. SSR bridge not fork-safe -gunicorn prefork shares file descriptors and Redis connections. - -### M2. cache_purge_user() not implemented -No way to purge all cache entries for one user. - -### M3. No garbage collection for context entries -Runtime `contexts` Map grows monotonically. - -### M4. No cross-tab invalidation -No BroadcastChannel. Logout in tab 1 doesn't affect tab 2. - -### M5. React 18 Strict Mode double-fetch -useEffect runs twice in dev mode. - -### M6. No request deduplication -Two components mounting same context fire parallel fetches. - -### M7. SSR worker module cache never invalidates -Dynamic imports cached forever. - -### M8. Vue injection key not exported -Can't inject directly without generated composables. - -### M9. Vue onMounted won't pre-fetch in Vue SSR -Needs `onServerPrefetch` for Nuxt. - -### M10. Svelte should use setContext/getContext -Module-level stores don't scope to component tree. - -### M12. render_strategy heuristic uses hardcoded param names -Misses `member_id`, `customer_id`, non-English names. - -### M13. initSession called for token-auth requests -Wastes GET /session/ round-trip for JWT/MWT apps. - -### M14. Vue watch imported but unused -Params not watched — reactive param changes don't trigger refetch. - -### M15. Vue mutation composables misleading `use` prefix -`export const useXxx = callXxx` — not a real composable. - -### M16. Svelte mutation imports bypass Stage 1 index -Should import from `'../index'` consistently. - -### M17. Side effects in React state updater -Context listeners called inside `setContextStore()` updater. - -## Architectural / Cleanup Debt - -### A1. Legacy MizanProvider not yet removed -**File:** `mizan-react/src/context.tsx` (~750 lines) -Superseded by the kernel (`mizan-base`) + generated React adapter (`useSyncExternalStore`). Still exported as `MizanProvider`, `useMizan`, `useMizanContext`, etc. Must be deleted or replaced with thin shims that call `configure()` + delegate to the new generated hooks. - -### A2. Allauth pending extraction -**File:** `legacy/allauth/` (44 files) -Sitting in `legacy/` since the cleanup pass. Should become its own `mizan-django-allauth` package consuming Mizan's public API. Unblocks v1 mizan-react publishing. - -### A3. Forms codegen not adapted to kernel -**File:** `mizan-react/src/forms.ts` (~1163 lines) -Still uses `useMizan().call()` from the legacy MizanProvider. Needs rewrite to use `mizanCall` from the kernel. Currently the only consumer of MizanProvider — blocks A1. - -### A4. Codegen for Vue/Svelte not validated end-to-end -The Stage 2 templates produce code that compiles, but no example app exercises Vue or Svelte rendering against a live backend. React is the only adapter with full integration verification. - -### A5. ROADMAP.md is stale -**File:** `ROADMAP.md` -Lists SSR Bridge, Edge Manifest, Codegen Rewrite, etc. as "Next" — all are done. Doesn't reflect: -- Two-stage codegen with Vue/Svelte adapters -- C6 kernel-owned state (`ContextState`) -- mizan-ts cross-language adapter -- Cleanup of djarea/Django-specific naming - -### A6. CLAUDE.md may also be stale -**File:** `CLAUDE.md` -Written before the kernel rewrite. References to MizanProvider responsibilities and the old codegen pattern are likely outdated. Needs audit. - -## Test Coverage Gaps - -### T1. No tests for C6 kernel state machine -**File:** `mizan-base/` has no `tests/` directory at all -The state-owning kernel has zero unit tests. No coverage of: -- `registerContext` returning `getState/subscribe/refetch/unregister` -- Status transitions: idle → loading → success/error -- Subscriber notifications on state change -- Refetch reusing the same entry on Strict Mode re-mount -- `unregister` clearing listeners - -### T2. No tests for generated Vue adapter output -The `vue.mjs` template produces code, but no test verifies it generates valid Vue 3 composables, that `onServerPrefetch` is wired correctly, or that the kernel subscription bridges to Vue reactivity. - -### T3. No tests for generated Svelte adapter output -Same as T2. Readable store factory pattern is unverified against actual Svelte components. - -### T4. No tests for view-path cache purge (C7 fix unverified) -The fix added `_purge_cache_for_invalidation()` to the view-path branch, but no test asserts that an `HttpResponse`-returning mutation actually purges the origin cache. - -### T5. No tests for SSR thread safety (C4 fix unverified) -The `_write_lock` was added but no concurrent-render test exists to prove it prevents JSON interleaving. - -### T6. No tests for SSR atexit cleanup (C5 fix unverified) -`atexit.register(self.shutdown)` was added but not exercised — no test that asserts the Bun process is reaped on Python exit. - -### T7. No tests for SSR hydration injection (C3 fix unverified) -The `` was added to template output but no test asserts it appears in rendered HTML or that the JSON is valid/safe. - -### T8. No cross-language HMAC pin test for booleans/None (H11 fix unverified) -Python now normalizes True→"true", but there's no test comparing Python's `derive_cache_key(secret, ctx, {flag: True})` against TypeScript's equivalent to prove they produce identical hex output. - -### T9. No tests for retry logic (H3) -`fetchWithRetry` retries 5xx/network errors with backoff. No test for: 5xx triggers retry, 4xx does not, mutation calls bypass retry, max retries respected. - -### T10. No end-to-end integration test -Nothing exercises the full pipeline: Django function defined → schema exported → codegen runs → generated React mounts → mutation fires → server response includes invalidate → kernel refetches → DOM updates. Each layer is tested in isolation. - -### T11. No tests for `isValid` requiring all required fields touched (H13 fix unverified) -The forms fix checks `field.required && !touched` but no test exercises a form with untouched required fields to confirm `isValid === false`. - -### T12. No tests for `_meta` fresh-dict isolation (H10 fix unverified) -The shared-dict fix replaced `{**FunctionWrapper._meta, **meta}` with `{**meta}`. No test confirms that mutating one function's `_meta` doesn't leak into others. +- [x] **Codegen test suite compile break** — every `mizan-codegen` test constructed `SourceConfig` without the `rust`/`script` fields added alongside the Rust-backend work. Suite now compiles and is green. +- [x] **React parity baseline** — the emitter correctly drops the dead `initSession`/`MizanError` top-level imports (they are only re-exported, never used in the module body); baseline regenerated. Fixed the template whitespace artifact that indented the `} from '@mizan/base'` closing brace. +- [x] **Edge manifest non-determinism** — `generate_edge_manifest` iterated registration order; now sorts context and mutation keys, so the manifest is deterministic regardless of registration order. +- [x] **Dead code removed** — `workers/mizan-ssr/src/test-worker.tsx` (a relic of the rejected `registerComponent` registry), unused TS helpers `isResponseReturn` and `sortedStringify` (mizan-ts), the unused `IndexMap` import (`emit/python.rs`), the dead `debug_expose_names` Django setting, and the dead `package.json` exports + vite aliases (`./client/nextjs`, `./allauth`, `./allauth/nextjs`) pointing at source that does not exist. diff --git a/MIZAN.md b/MIZAN.md index b25cd2e..8fccb1d 100644 --- a/MIZAN.md +++ b/MIZAN.md @@ -1,14 +1,19 @@ # MIZAN — Named Contexts & Mutation Architecture +> **Historical design spec.** The original named-contexts / mutation design +> document from the January 2025 design conversation. Kept as a record of design +> intent, not as a description of the current build — names and surfaces here +> predate the implementation (the codegen is the Rust binary +> `protocol/mizan-codegen`, never shipped under the working name "Maison"). For +> current architecture, read `CLAUDE.md` (wire protocol, package layout, codegen +> state) and `docs/` (`AFI_ARCHITECTURE.md`, `SSR_ARCHITECTURE.md`, +> `CACHE_KEYING.md`, `MWT_SPEC.md`). + ## 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. +several rounds of architectural refinement. --- diff --git a/ROADMAP.md b/ROADMAP.md index 594ed74..8de5c2b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,47 +4,38 @@ ### Done -- **`@client` decorator** — `context=`, `affects=`, `auth=`, `websocket=`, `private=`, `route=`, `methods=`, `rev=`, `cache=` -- **`ReactContext` class** — type-safe context/affects references with linting -- **Named contexts** — functions sharing a context name grouped into one provider and one fetch -- **Context bundling endpoint** — `GET /api/mizan/ctx//` returns all functions in one response -- **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}` -- **`X-Mizan-Invalidate` header** — second invalidation transport for view-path responses (redirects, HTML) -- **Return-type branching** — data return → RPC path; `HttpResponse` return → view path -- **Scoped invalidation** — `affects_params` lambda; runtime supports `{context, params}` form -- **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable` -- **JWT + session auth** — auto-detected, CSRF handled -- **MWT** — Mizan Web Token for Edge cache keying (separate secret from JWT/cache) -- **Shapes** — Pydantic + django-readers for typed query projections -- **WebSocket channels** — real-time bidirectional communication -- **HMAC cache keying** — origin-side cache with cross-language HMAC conformance (Python + TypeScript pin) -- **Edge manifest** — `python manage.py export_edge_manifest`; both RPC and view-path functions -- **SSR bridge** — Django template backend → persistent Bun subprocess via JSON-RPC -- **`mizan-base` kernel** — framework-agnostic imperative client primitives (data/status/error owned by kernel) -- **Two-stage codegen** — Stage 1 emits framework-agnostic protocol layer; Stage 2 emits per-framework hooks (React, Vue, Svelte) -- **`mizan-ts`** — TypeScript backend adapter; proves the protocol is language-agnostic +- [x] **`@client` decorator** — `context=`, `affects=`, `auth=`, `websocket=`, `private=`, `route=`, `methods=`, `rev=`, `cache=` +- [x] **`ReactContext` class** — type-safe context/affects references with linting +- [x] **Named contexts** — functions sharing a context name grouped into one provider and one fetch +- [x] **Context bundling endpoint** — `GET /api/mizan/ctx//` returns all functions in one response +- [x] **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}` +- [x] **`X-Mizan-Invalidate` header** — second invalidation transport for view-path responses (redirects, HTML) +- [x] **Return-type branching** — data return → RPC path; `HttpResponse` return → view path +- [x] **Scoped invalidation** — `affects_params` lambda; runtime supports `{context, params}` form +- [x] **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable` +- [x] **JWT + session auth** — auto-detected, CSRF handled +- [x] **MWT** — Mizan Web Token for Edge cache keying (separate secret from JWT/cache) +- [x] **Shapes** — Pydantic + django-readers for typed query projections +- [x] **WebSocket channels** — real-time bidirectional communication +- [x] **HMAC cache keying** — origin-side cache with cross-language HMAC conformance (Python + TypeScript pin) +- [x] **Edge manifest** — `python manage.py export_edge_manifest`; both RPC and view-path functions; deterministic (sorted) output +- [x] **SSR bridge** — Django template backend → persistent Bun subprocess via JSON-RPC; the worker resolves components by file path (`import(file)` + `renderToString`) +- [x] **`mizan-base` kernel** — framework-agnostic imperative client primitives (data/status/error owned by kernel) +- [x] **Rust codegen** — `protocol/mizan-codegen`, a Rust binary reading KDL IR and emitting per-target clients (react, vue, svelte, channels, stage1, python, rust), each byte-parity-tested. `mizan-generate` is the thin npm launcher. +- [x] **React wrapper layer** — codegen emits the `MizanContext` root provider, `useMizan` escape hatch, and `useMutation`-backed hooks exposing `{ mutate, isPending, error }` +- [x] **Additional backend adapters** — `mizan-ts` (TypeScript), `mizan-rust-axum` (Rust/Axum with three-way parity), `mizan-tauri` +- [x] **Frontend transports** — `mizan-tauri-transport`, `mizan-webview-transport`, `mizan-webview-channels` --- -### Next (in progress) +### Next -- **React adapter wrapper layer** — codegen emits `MizanContext` provider, `useMizan` hook, `DjangoError` class on top of the `mizan-base` kernel. Equivalent wrapper layers for Vue and Svelte adapters. The harness in `examples/django-react-site` is blocked on this. -- **Legacy `MizanProvider` removal (A1)** — `mizan-react/src/context.tsx` (~750 lines) replaced by codegen-emitted wrappers. Blocks v1 `mizan-react` publishing. -- **Forms migration to kernel (A3)** — `mizan-react/src/forms.ts` (~1163 lines) currently consumes legacy `MizanProvider`. Rewrite to use `mizanCall` from the kernel. Blocks A1. -- **Allauth extraction (A2)** — `legacy/allauth/` becomes `mizan-django-allauth` package consuming Mizan's public API. -- **Vue/Svelte e2e validation (A4)** — example apps exercising a live backend end-to-end, like `examples/django-react-site` does for React. -- **Test coverage gaps** — T1–T12 in `ISSUES.md` (kernel state machine, view-path purge, SSR thread safety, retry logic, cross-language HMAC pin, etc.) - ---- - -### Quality - -- **H5** — Mutation hooks expose no loading/error state -- **H7** — Redis SCAN blocks request path at scale -- **H8** — Svelte codegen uses Svelte 4 stores; should use Svelte 5 runes -- **H9** — Svelte `destroy()` not auto-called (memory leak) -- **H12** — Forms `triggerValidation` captures stale data -- Medium issues (M1–M18) per developer judgment +- [ ] **Vue / Svelte runtime packages** — `frontends/mizan-vue` and `frontends/mizan-svelte` are unimplemented stubs. The codegen emits their clients (byte-parity-tested), but a kernel-adapter runtime package and a live-backend example are owed for each. +- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`. +- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider. +- [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired. +- [ ] **Cache hardening** — purge atomicity, per-param sub-index cleanup, thundering-herd protection, RedisCache coverage (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`). +- [ ] **Package READMEs** — `mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`). --- diff --git a/backends/mizan-django/README.md b/backends/mizan-django/README.md index 266f23e..6c130a2 100644 --- a/backends/mizan-django/README.md +++ b/backends/mizan-django/README.md @@ -144,40 +144,33 @@ Frontend gets `useChatChannel({ room })`. ## Generate the frontend -The codegen is `mizan-generate` (in `protocol/mizan-generate/`). From your -frontend project, point a config at the Django backend and run the CLI: +The codegen is the `mizan-generate` Rust binary (source at +`protocol/mizan-codegen/`; `protocol/mizan-generate/` is a thin npm +launcher that dispatches to the platform binary). From your frontend +project, point a `mizan.toml` at the Django backend and run the CLI: -```js -// frontend/django.config.mjs -import path from "path" -import { fileURLToPath } from "url" +```toml +# frontend/mizan.toml +output = "src/api" +targets = ["react"] -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const root = path.resolve(__dirname, "..") +[source.django] +manage_path = "../backend/manage.py" +command = ["uv", "run", "python"] # optional — defaults to ["python"] -export default { - source: { - django: { - managePath: path.join(root, "backend/manage.py"), - command: ["uv", "run", "python"], - env: { - PYTHONPATH: path.join(root, "backend"), - DJANGO_SETTINGS_MODULE: "myproject.settings", - }, - }, - }, - output: "src/api", -} +[source.django.env] +PYTHONPATH = "../backend" +DJANGO_SETTINGS_MODULE = "myproject.settings" ``` ```bash -npx mizan-generate --config django.config.mjs +mizan-generate --config mizan.toml ``` -The codegen drives Django's management command (`export_mizan_schema`) under -the hood, then emits Stage 1 (typed `callXxx`/`fetchXxx` over the runtime -kernel) + Stage 2 (`` provider, per-context providers, -`use{Hook}()` hooks) into `src/api/`. +The codegen drives Django's management command (`export_mizan_ir`) under +the hood, parses the emitted KDL IR, then emits Stage 1 (typed +`callXxx`/`fetchXxx` over the runtime kernel) + Stage 2 (`` +provider, per-context providers, `use{Hook}()` hooks) into `src/api/`. ```tsx // app.tsx diff --git a/backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md b/backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md index 40b90fc..0f0dcaa 100644 --- a/backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md +++ b/backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md @@ -1,65 +1,40 @@ # Cache Module — Known Issues -Issues identified by 8-domain-expert review. Status tracked here. +Open issues against the current cache implementation. Resolved items are +removed once their fix lands. -## Critical (Security / Data Corruption) +## Correctness -### 1. ~~User-scoped content cached without user_id~~ FIXED -`context_fetch_view` now extracts `user_id` from `request.user.pk` and -passes it to `cache_get`/`cache_put`. +### Purge race condition (non-atomic index operations) +`cache_purge` reads the index and deletes as separate operations. A +concurrent `cache_put` between the two steps can orphan entries. Mitigated +by AND-intersection purge semantics, but full atomicity (Lua script or +`WATCH`/`MULTI` on the Redis backend) is still owed. -### 2. Purge race condition (non-atomic index operations) -`cache_purge` does index reads and deletes as separate operations. -Concurrent `cache_put` between steps can orphan entries. -**Status:** Partially mitigated by AND semantics fix. Full atomicity -(Lua script or WATCH/MULTI) still needed for Redis backend. +### Cross-language stringification divergence +Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize` +canonicalizes `True`/`False`/`None` today, but the rules for the remaining +value types are not yet pinned in the protocol spec — so Python and +TypeScript HMAC keys can still diverge on an un-normalized type. -### 3. ~~No Redis error handling~~ FIXED -All cache operations in `executor.py` wrapped in try/except with -`logger.warning`. Redis failure falls through to uncached execution. +## Performance / Operability -### 4. ~~Scoped purge uses OR semantics~~ FIXED -Changed to AND (intersection). `{user_id: 5, org_id: 3}` now only -deletes entries matching BOTH params. +### Broad purge leaves per-param sub-indexes +A broad `cache_purge(context)` deletes the entries but not the per-param +sub-indexes — a slow Redis memory leak. -## High (Correctness / Operability) +### No thundering-herd protection +Concurrent cold misses on the same key all execute and write. No +single-flight / request-coalescing. -### 5. ~~No TTL on Redis entries~~ FIXED -`RedisCache.put` now sets `ex=86400` (24h safety-net TTL) by default. +## API shape -### 6. Cross-language str() vs String() divergence -Python `str(True)` -> `"True"`, JS `String(true)` -> `"true"`. -**Status:** Open. Needs canonical stringification rules in protocol spec. +### cache_get / cache_put argument inconsistency +`cache_get`/`cache_put` take explicit args while the executor resolves some +inputs from module globals — two access patterns for one concern. -### 7. Broad purge doesn't clean per-param sub-indexes -**Status:** Open. Slow memory leak in Redis. +## Coverage -### 8. ~~build_index_keys doesn't stringify values~~ FIXED -Now calls `str(v)` on all values, matching `derive_cache_key`. - -### 9. ~~Silent exception swallowing in get_cache()~~ FIXED -Now logs warnings for partial config and connection failures. - -### 10. ~~_initialized flag not thread-safe~~ FIXED -Now uses `threading.Lock` for thread-safe initialization. - -## Medium (Design / Performance) - -### 11. No thundering-herd protection -**Status:** Open. Concurrent cold misses all execute and write. - -### 12. ~~Wire-protocol internals in __all__~~ FIXED -`derive_cache_key` and `build_index_keys` removed from `__all__`. - -### 13. Inconsistent API pattern -**Status:** Open. `cache_get`/`cache_put` take explicit args but executor -fetches from globals. - -### 14. ~~clear() uses SCAN + DELETE without pipeline~~ FIXED -Now uses pipeline with UNLINK for batched async deletes. - -### 15. ~~No Redis connection timeouts~~ FIXED -`socket_connect_timeout=5`, `socket_timeout=5`, `health_check_interval=30`. - -### 16. No RedisCache test coverage -**Status:** Open. Only MemoryCache is tested. +### RedisCache lacks test coverage +Only `MemoryCache` is exercised by the suite. `RedisCache` (connection +pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested. diff --git a/backends/mizan-django/src/mizan/export/__init__.py b/backends/mizan-django/src/mizan/export/__init__.py index cb9ec2c..230b7d0 100644 --- a/backends/mizan-django/src/mizan/export/__init__.py +++ b/backends/mizan-django/src/mizan/export/__init__.py @@ -56,7 +56,7 @@ def generate_edge_manifest( manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}} - for ctx_name, fn_names in groups.items(): + for ctx_name, fn_names in sorted(groups.items()): param_names: set[str] = set() functions_meta: list[dict[str, Any]] = [] page_routes: list[str] = [] @@ -107,7 +107,7 @@ def generate_edge_manifest( manifest["contexts"][ctx_name] = ctx_entry - for fn_name, fn_cls in all_functions.items(): + for fn_name, fn_cls in sorted(all_functions.items()): meta = getattr(fn_cls, "_meta", {}) if not meta.get("affects"): continue diff --git a/backends/mizan-django/src/mizan/setup/settings.py b/backends/mizan-django/src/mizan/setup/settings.py index f032f62..4277840 100644 --- a/backends/mizan-django/src/mizan/setup/settings.py +++ b/backends/mizan-django/src/mizan/setup/settings.py @@ -14,9 +14,6 @@ from django.conf import settings as django_settings class mizanSettings: """mizan configuration.""" - # Whether to expose function names in DEBUG mode errors - debug_expose_names: bool - # Cache HMAC signing secret (required when cache is enabled) cache_secret: str | None @@ -36,12 +33,10 @@ def get_settings() -> mizanSettings: Load mizan settings from Django settings. Settings: - mizan_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True) MIZAN_CACHE_SECRET: HMAC signing key for cache keys (default: None) MIZAN_CACHE_REDIS_URL: Redis connection URL (default: None) """ return mizanSettings( - debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True), cache_secret=getattr(django_settings, "MIZAN_CACHE_SECRET", None), cache_redis_url=getattr(django_settings, "MIZAN_CACHE_REDIS_URL", None), mwt_secret=getattr(django_settings, "MIZAN_MWT_SECRET", None), diff --git a/backends/mizan-fastapi/README.md b/backends/mizan-fastapi/README.md index c27b5d1..0d2d798 100644 --- a/backends/mizan-fastapi/README.md +++ b/backends/mizan-fastapi/README.md @@ -108,37 +108,30 @@ anonymous request. The executor branches on those for `auth=True`, ## Generate the frontend -The codegen is `mizan-generate` (in `protocol/mizan-generate/`). Point a -config at your FastAPI app and run the CLI: +The codegen is the `mizan-generate` Rust binary (source at +`protocol/mizan-codegen/`; `protocol/mizan-generate/` is a thin npm +launcher that dispatches to the platform binary). Point a `mizan.toml` at +your FastAPI app and run the CLI: -```js -// frontend/fastapi.config.mjs -import path from "path" -import { fileURLToPath } from "url" +```toml +# frontend/mizan.toml +output = "src/api" +targets = ["react"] -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const root = path.resolve(__dirname, "..") - -export default { - source: { - fastapi: { - module: "main", // module to import for @client side effects - cwd: path.join(root, "backend"), // python cwd for module resolution - command: ["uv", "run", "python"], // optional — defaults to ["python"] - }, - }, - output: "src/api", -} +[source.fastapi] +module = "main" # module to import for @client side effects +cwd = "../backend" # python cwd for module resolution +command = ["uv", "run", "python"] # optional — defaults to ["python"] ``` ```bash -npx mizan-generate --config fastapi.config.mjs +mizan-generate --config mizan.toml ``` -The codegen drives `python -m mizan_fastapi.cli ` under the hood, -then emits Stage 1 (typed `callXxx`/`fetchXxx` over the runtime kernel) + -Stage 2 (`` provider, per-context providers, `use{Hook}()` -hooks) into `src/api/`. +The codegen drives `python -m mizan_fastapi.ir ` under the hood, +parses the emitted KDL IR, then emits Stage 1 (typed `callXxx`/`fetchXxx` +over the runtime kernel) + Stage 2 (`` provider, per-context +providers, `use{Hook}()` hooks) into `src/api/`. ```tsx // app.tsx @@ -171,13 +164,13 @@ uv run pytest For codegen consumption (or any tooling that wants the Mizan schema): ```bash -python -m mizan_fastapi.cli +python -m mizan_fastapi.ir ``` Imports the named module (which must register every `@client` function as -import-time side effects), then prints the OpenAPI schema as JSON to stdout. -Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen -consumes either backend the same subprocess way. +import-time side effects), then prints the Mizan KDL IR to stdout. +Mirrors mizan-django's `manage.py export_mizan_ir` so the codegen consumes +either backend the same subprocess way. ## Architecture diff --git a/backends/mizan-ts/src/decorator.ts b/backends/mizan-ts/src/decorator.ts index 995f56c..34b3a06 100644 --- a/backends/mizan-ts/src/decorator.ts +++ b/backends/mizan-ts/src/decorator.ts @@ -52,10 +52,6 @@ function extractParams(fn: Function): ParamDef[] { }) } -function isResponseReturn(result: any): boolean { - return result instanceof Response -} - /** * Function wrapper — registers a standalone function. * diff --git a/backends/mizan-ts/src/dispatch.ts b/backends/mizan-ts/src/dispatch.ts index 8e7c9f1..5159769 100644 --- a/backends/mizan-ts/src/dispatch.ts +++ b/backends/mizan-ts/src/dispatch.ts @@ -22,10 +22,6 @@ export interface MizanResponse { headers: Record } -function sortedStringify(data: any): string { - return JSON.stringify(data, Object.keys(data).sort()) -} - /** * Handle GET /api/mizan/ctx/:contextName/ * diff --git a/cores/mizan-rust-macros/Cargo.lock b/cores/mizan-rust-macros/Cargo.lock new file mode 100644 index 0000000..f098248 --- /dev/null +++ b/cores/mizan-rust-macros/Cargo.lock @@ -0,0 +1,54 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "mizan-macros" +version = "0.1.0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/docs/AFI_ARCHITECTURE.md b/docs/AFI_ARCHITECTURE.md index 7b25477..d6d1f34 100644 --- a/docs/AFI_ARCHITECTURE.md +++ b/docs/AFI_ARCHITECTURE.md @@ -9,18 +9,31 @@ Tree organized by role. ``` backends/ server protocol adapters - mizan-django/ Django adapter - mizan-fastapi/ FastAPI adapter (AFI-common scope) - mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic) -frontends/ client kernel + per-framework adapters - mizan-base/ framework-agnostic kernel; owns data, status, error; adapters subscribe - mizan-react/ React contexts + hooks over the kernel - mizan-vue/ Vue composables over the kernel - mizan-svelte/ Svelte stores/runes over the kernel + mizan-django/ Django adapter + mizan-fastapi/ FastAPI adapter (AFI-common scope) + mizan-rust-axum/ Rust/Axum adapter (handlers, errors, IR export) + mizan-tauri/ Tauri adapter — Mizan calls served in-process + mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic) +frontends/ client kernel + per-framework adapters + transports + mizan-base/ framework-agnostic kernel (@mizan/base); owns data, status, + error; adapters subscribe through the MizanTransport interface + mizan-react/ React contexts + hooks over the kernel + mizan-vue/ Vue composables over the kernel + mizan-svelte/ Svelte stores/runes over the kernel + mizan-rust/ Rust client kernel + mizan-tauri-transport/ MizanTransport over Tauri IPC + mizan-webview-transport/ MizanTransport over a webview message channel + mizan-webview-channels/ channel transport over a webview bridge cores/ shared language-level primitives - mizan-python/ @client decorator, registry, MWT, HMAC cache keys + mizan-python/ @client decorator, registry, MWT, HMAC cache keys + mizan-rust/ Rust core — IR build (build_ir()), registry + mizan-rust-macros/ #[derive(Mizan)] / #[mizan::client] proc-macros protocol/ protocol-level tooling - mizan-generate/ codegen — schema in, typed client out + mizan-codegen/ codegen — Rust binary (crate `mizan-codegen`); reads KDL IR, + emits typed clients. Targets: stage1, react, vue, svelte, + channels, python, rust. Askama templates under templates/. + mizan-generate/ thin npm-package launcher (bin/launcher.mjs) dispatching to + the compiled mizan-codegen binary per platform workers/ runtime workers / bridges mizan-ssr/ Bun subprocess used by the Django template backend ``` @@ -35,11 +48,16 @@ compose. ## Kernel model -The client kernel (`mizan-base`) is the one hard thing. Per- -framework adapters are thin idiomatic wrappers around it. Codegen -emits typed bindings against the framework adapter's surface, not -against the raw kernel — so a React developer gets `useEcho()` and -``, a Vue developer gets `useEcho()` composables, a +The client kernel (`@mizan/base`) is the one hard thing. It owns +`ContextState = {data, status, error}`, the context registry +(`registerContext`), `mizanCall` / `mizanFetch`, server-driven `merge` +and `invalidate`, and `initSession`. It reaches the backend through a +pluggable `MizanTransport` (`call` / `fetch`); the default is the +HTTP `httpTransport()`, swapped via `configure({ transport })` for +Tauri / webview hosts. Per-framework adapters are thin idiomatic +wrappers that subscribe to the kernel. Codegen emits typed bindings +against the framework adapter's surface — a React developer gets +`useEcho()` hooks, a Vue developer gets `useEcho()` composables, a Svelte developer gets readable stores. Same kernel underneath. ## KDL is the IR @@ -56,20 +74,23 @@ divergence between adapters is what the IR exists to prevent. Forward-direction primitives: -- `cores/mizan-python` builds the IR from registered functions - (`build_ir()` walks `mizan_core.registry`, emits KDL) -- A `mizan-schema` package (forthcoming) holds the canonical KDL - grammar / type system definition that every adapter targets +- Each backend adapter emits KDL on stdout from an IR-export command: + FastAPI `python -m mizan_fastapi.ir `, Django + `python manage.py export_mizan_ir`, Rust a consumer-side cargo bin + that calls `mizan_core::build_ir()`. Python's `build_ir()` walks + `mizan_core.registry`. The IR grammar (`type` / `function` / + `context` / `channel` nodes) is parsed by `mizan-codegen`'s + `src/ir.rs`; fixtures live at + `protocol/mizan-codegen/tests/fixtures/*.kdl`. +- `protocol/mizan-codegen/src/fetch.rs` spawns the configured source + command and parses the KDL it writes. - Codegen reads KDL directly — no OpenAPI envelope, no - `openapi-typescript`, no per-backend converter divergence -- Edge manifest, MWT claims, and other protocol artifacts all derive - from the same KDL - -**Current implementation is transitional.** Today the codegen consumes -OpenAPI 3.0 (`x-mizan-functions` + `x-mizan-contexts` extensions over -Pydantic→JSON-Schema), produced via Django Ninja or FastAPI's native -generator. That layered indirection is what introduces adapter -divergence (see the AFI conformance suite). KDL-as-IR collapses it. + `openapi-typescript`, no per-backend converter divergence. The + former JavaScript/Node two-stage codegen (`openapi-typescript` plus + `.mjs` adapters) has been deleted; codegen is now the single Rust + binary. +- Edge manifest, MWT claims, and other protocol artifacts derive from + the same registry/IR. ## Launch surface diff --git a/docs/CACHE_KEYING.md b/docs/CACHE_KEYING.md index 182169b..9a0286a 100644 --- a/docs/CACHE_KEYING.md +++ b/docs/CACHE_KEYING.md @@ -16,14 +16,22 @@ standardized replacement exists. ## Resolution: HMAC cache key (JSON-canonical form) ``` -HMAC-SHA256(secret, JSON.stringify({ +ctx:{context}:HMAC-SHA256(secret, json.dumps({ "c": context, - "p": sorted_params, + "p": sorted_params, // values normalized to JSON-native strings "r": rev, - "u": user_id // omitted for public content -}, sort_keys=True)) + "u": user_id // omitted for public content +}, sort_keys=True, separators=(",", ":"))) ``` +`derive_cache_key(secret, context, params, user_id=None, rev=0)` → +`"ctx:{context}:{hmac_hex}"`. The `ctx:{context}:` prefix lets broad +purge SCAN by prefix. Param values are normalized for cross-language +consistency (`True`→`"true"`, `None`→`"null"`) before stringification. +Implemented in `cores/mizan-python/src/mizan_core/cache/keys.py` and +`backends/mizan-ts/src/cache/keys.ts` (`deriveCacheKey`); pin tests +verify identical output. + ### Key derivation rules - **Public content** — URL path + query params (standard CDN). @@ -45,20 +53,24 @@ Mizan claims on `X-Mizan-Token` header. Replaces the old **Not a compiled binary ABI. Not a pluggable Python protocol.** Each backend adapter (Python, TypeScript, future PHP/C#/Go) -implements the cache protocol in its own language, backed by Redis. +implements the cache protocol in its own language. **Conformance verified by a shared test suite.** ### Required operations - `cache_get` - `cache_put` -- `cache_purge` -- `cache_purge_user` +- `cache_purge` (scoped recomputes the key; broad SCANs the + `ctx:{context}:*` prefix) ### Storage -Redis only. Handles persistence, cross-worker sharing, crash -recovery. +Two backends behind a `CacheBackend` protocol +(`mizan_core/cache/backend.py`): + +- `MemoryCache` — dict-based, for testing. +- `RedisCache` — production; persistence, cross-worker sharing, crash + recovery. Broad purge via SCAN, delete via UNLINK. ## Deploy invalidation diff --git a/docs/MWT_SPEC.md b/docs/MWT_SPEC.md index 0a9b6cb..17e0239 100644 --- a/docs/MWT_SPEC.md +++ b/docs/MWT_SPEC.md @@ -15,8 +15,10 @@ detection. | `pkey` | Deterministic hash of user's permission state at issuance | | `exp` | Configurable short TTL — controls permission staleness window (Django setting) | | `iat` | Issued at | -| `kid` | Key ID — for secret rotation | +| `kid` | Key ID — for secret rotation. Carried in the JOSE header (RFC 7515), not the payload | | `aud` | Audience binding — prevents cross-tenant replay | +| `nbf` | Not-before — tolerates clock skew | +| `staff` / `super` | `is_staff` / `is_superuser`, used to build `MWTUser` without a DB query | ## Key decisions @@ -25,10 +27,14 @@ detection. - **`X-Mizan-Token` header, not `Authorization: Bearer`.** Avoids collision with DRF, allauth, and existing JWT systems. Cloudflare WAF/Access do not inspect custom headers. -- **Replaces `JWTUser` + `_try_jwt_auth` entirely.** Old approach is - deleted. +- **`MWTUser`** is a minimal, DB-free request user built from the + token claims (`cores/mizan-python/src/mizan_core/mwt.py`). + > A separate JWT module (`mizan/jwt/`) still exists for standard + > user-auth access/refresh tokens; MWT is the cache-keying identity + > layer, not a replacement for that module. - **App handles authentication** (session, social, etc.). Mizan - issues MWT *from* the authenticated identity. + issues MWT *from* the authenticated identity + (`create_mwt(user, secret, ttl, audience, kid)`). - **Edge Worker** validates MWT, extracts `sub` for HMAC cache key, checks `exp`. - **`pkey` computation must be deterministic:** @@ -43,9 +49,11 @@ detection. JSON with sorted keys: ``` -HMAC(secret, JSON.stringify({"c": context, "p": sorted_params, "u": user_id})) +ctx:{context}:HMAC(secret, JSON.stringify({"c": context, "p": sorted_params, "r": rev, "u": user_id})) ``` +See [CACHE_KEYING.md](CACHE_KEYING.md) for the full derivation. + ## What this solves - DRF token collision @@ -55,5 +63,8 @@ HMAC(secret, JSON.stringify({"c": context, "p": sorted_params, "u": user_id})) ## Usage rule -All cache-layer auth code uses MWT, not Django session or raw JWT. -The `@client(auth=...)` parameter gates on MWT validity. +MWT is the identity Edge/cache layers key on. The `@client(auth=...)` +parameter is enforced server-side in `mizan/client/executor.py` +(`_check_auth_requirement`), which checks `request.user` against the +auth requirement (`required` / `staff` / `superuser` / callable); +`request.user` may be an `MWTUser` (stateless) or a session user. diff --git a/docs/PRODUCT_ARCHITECTURE.md b/docs/PRODUCT_ARCHITECTURE.md index ec4798f..733b5fe 100644 --- a/docs/PRODUCT_ARCHITECTURE.md +++ b/docs/PRODUCT_ARCHITECTURE.md @@ -22,17 +22,16 @@ multi-state privacy. ~$5–8K legal costs. TS "Deploy" exists via Workers for Platforms at no additional compliance cost. -## Free framework: mizan-cache (origin-side cache) +## Free framework: origin-side cache (`mizan.cache`) -Python package implementing the **full cache protocol locally** — -same HMAC key derivation, metadata schema, and purge semantics as -Edge. +Shipped in `mizan_core.cache` (re-exported as `mizan.cache` from the +Django adapter) implementing the **full cache protocol locally** — +same HMAC key derivation and purge semantics as Edge. -Three backends: +Two backends behind a `CacheBackend` protocol: -- In-memory dict (default) -- Redis -- SQLite +- `MemoryCache` — in-memory dict (testing) +- `RedisCache` — production ### Dual purpose @@ -44,8 +43,8 @@ Three backends: ## Spec additions - `@client(cache=False)` — uncacheable; emits `Cache-Control: no-store`. -- Cache ABI: `get(key)`, `put(key, response, metadata)`, - `purge(context, params)`. +- Cache ABI (`mizan.cache`): `cache_get(secret, backend, context, params)`, + `cache_put(...)`, `cache_purge(backend, context, params=…, secret=…)`. ## Launch compliance (Render only) diff --git a/docs/PSR_VS_EDGE.md b/docs/PSR_VS_EDGE.md index ac692fe..cd9dbdc 100644 --- a/docs/PSR_VS_EDGE.md +++ b/docs/PSR_VS_EDGE.md @@ -14,6 +14,15 @@ Works on a $5 VPS with local Bun. **No Edge required.** PSR is part of the protocol; it's available to every Mizan deployment regardless of hosting. +> Current state: the Edge manifest records each context's +> `render_strategy` (`"psr"` for public, `"dynamic_cached"` for +> user-scoped) — see `mizan/export/` and the `export_edge_manifest` +> management command — and the SSR bridge can render a component to +> HTML. The render-on-mutation orchestration that wires those together +> (mutation → trigger local render → store HTML) is not yet present in +> the open-source backends; it is the manifest-driven behavior the +> Edge layer consumes. + ## Edge Delivery — Mizan Render (Paid Product) Pre-rendered HTML cached globally on Cloudflare CDN. diff --git a/docs/SSR_ARCHITECTURE.md b/docs/SSR_ARCHITECTURE.md index 718c63b..36b453f 100644 --- a/docs/SSR_ARCHITECTURE.md +++ b/docs/SSR_ARCHITECTURE.md @@ -10,23 +10,31 @@ rendering engine. TEMPLATES = [ { 'BACKEND': 'mizan.ssr.MizanTemplates', - ... + 'DIRS': [BASE_DIR / 'frontend'], + 'OPTIONS': { + 'worker': 'path/to/mizan-ssr/src/worker.tsx', + 'timeout': 5, + }, } ] ``` -Then `render(request, 'ProfilePage', context)` calls the Bun +Then `render(request, 'components/Hello.tsx', context)` calls the Bun subprocess bridge instead of rendering a Django/Jinja2 template. -**The component name IS the template name.** +**The template name IS a `.tsx`/`.jsx` file path**, resolved against +`DIRS`; `get_template` returns a `MizanTemplate` wrapping the absolute +file path. The context dict becomes the component's props (`request` +and `csrf_token` stripped). Rendered output is wrapped in +`
` plus a +`` hydration payload. ## AFI boundary | Side | Responsibility | |---|---| -| Backend adapter | Implements `mizan.ssr()` — executes context functions, gathers data | -| Frontend adapter | Implements `renderToHTML()` — takes component + props, produces HTML | -| Bun subprocess | Hosts the frontend adapter | -| stdin/stdout JSON-RPC | Transport between the two | +| Backend adapter (`SSRBridge`) | Manages the Bun subprocess lifecycle; gathers props | +| Bun worker (`worker.tsx`) | `import()`s the file path, `renderToString(createElement(Component, props))` | +| stdin/stdout JSON-RPC | Newline-delimited; `{id, method:"render", params:{file, props}}` → `{id, html}` / `{id, error}`; `ping` → `{id, pong:true}` | ## Why template backend @@ -35,16 +43,22 @@ subprocess bridge instead of rendering a Django/Jinja2 template. - Django developers already use `render(request, template, context)` — no new API to learn. - URL routing, views, middleware, auth — all unchanged. -- The template tag `{% mizan_render %}` is a convenience for - developers who *also* use Django templates (e.g., a base.html shell - with Mizan components inside). + +> A `templatetags/` package exists for a future `{% mizan_render %}` +> convenience tag (base.html shell with Mizan components inside), but +> it is currently empty — no tag is implemented yet. ## Implementation surface -The SSR bridge module implements Django's template backend interface: +The SSR backend (`mizan/ssr/backend.py`) implements Django's template +backend interface: -- `BaseEngine` subclass -- `Template` class with `.render(context, request)` +- `MizanTemplates(BaseEngine)` — requires `OPTIONS['worker']` (path to + `worker.tsx`); `get_template(name)` resolves a file under `DIRS` +- `MizanTemplate` with `.render(context, request)` → calls the bridge +- `SSRBridge` (`bridge.py`) — spawns `bun run `, holds the + persistent subprocess, correlates requests by message id, thread-safe, + auto-restarts on crash, waits for the worker's ready signal Everything Django expects from a template backend, but the actual rendering routes to Bun. diff --git a/examples/django-react-site/harness/vite.config.ts b/examples/django-react-site/harness/vite.config.ts index ee1caf7..2ea643f 100644 --- a/examples/django-react-site/harness/vite.config.ts +++ b/examples/django-react-site/harness/vite.config.ts @@ -10,11 +10,8 @@ export default defineConfig({ alias: { 'mizan/channels': path.join(reactPkg, 'channels/index.ts'), 'mizan/client/react': path.join(reactPkg, 'client/react.ts'), - 'mizan/client/nextjs': path.join(reactPkg, 'client/nextjs.tsx'), 'mizan/client': path.join(reactPkg, 'client/index.ts'), 'mizan/jwt': path.join(reactPkg, 'jwt/index.ts'), - 'mizan/allauth/nextjs': path.join(reactPkg, 'allauth/nextjs.tsx'), - 'mizan/allauth': path.join(reactPkg, 'allauth/index.ts'), 'mizan': path.join(reactPkg, 'index.ts'), '@rythazhur/mizan/channels': path.join(reactPkg, 'channels/index.ts'), '@rythazhur/mizan/jwt': path.join(reactPkg, 'jwt/index.ts'), diff --git a/frontends/mizan-react/README.md b/frontends/mizan-react/README.md index 349844f..7ee787c 100644 --- a/frontends/mizan-react/README.md +++ b/frontends/mizan-react/README.md @@ -12,39 +12,44 @@ npm install @rythazhur/mizan@git+https://git.impactsoundworks.com/isw/mizan.git# You don't use this package directly. You use the **generated hooks**. +This is the pre-kernel React adapter: it ships its own `MizanProvider` +(`src/context.tsx`) that owns HTTP/WebSocket/CSRF/session/context state +directly, rather than subscribing to the `@mizan/base` kernel. It is still +the provider the Django + desktop example wires against. (`DjangoContext`, +`useDjango`, etc. are deprecated aliases for the `Mizan*` names.) + ### 1. Configure -```js -// django.config.mjs -export default { - source: { - django: { - managePath: '../backend/manage.py', - command: ['uv', 'run', 'python'], - }, - }, - output: 'src/api/generated.ts', -} +```toml +# mizan.toml +output = "src/api" +targets = ["react"] + +[source.django] +manage_path = "../backend/manage.py" +command = ["uv", "run", "python"] ``` ### 2. Generate +The codegen is the `mizan-generate` Rust binary (source at +`protocol/mizan-codegen/`; `protocol/mizan-generate/` is the npm launcher): + ```bash -npx mizan-generate # once -npx mizan-generate --watch # dev mode +mizan-generate --config mizan.toml ``` ### 3. Wrap your app ```tsx -import { DjangoContext } from '@/api' +import { MizanProvider } from '@rythazhur/mizan' - + - + ``` -`DjangoContext` is the only provider you need. It handles HTTP, WebSocket, CSRF, session init, context auto-fetching, and channel connections. +`MizanProvider` is the only provider you need. It handles HTTP, WebSocket, CSRF, session init, context auto-fetching, and channel connections. ### 4. Use generated hooks @@ -71,19 +76,22 @@ chat.messages // typed, reactive ## Generated Files +The Rust codegen emits per-target files into the configured `output` +directory (Stage 1 is auto-included whenever `react` is a target): + | File | Contents | |------|----------| -| `generated.django.tsx` | `DjangoContext` + typed hooks | -| `generated.mizan.ts` | Pydantic types | -| `generated.forms.ts` | Form hooks with Zod | -| `generated.channels.hooks.tsx` | Channel hooks | -| `index.ts` | Re-exports everything | +| `types.ts` | Pydantic types | +| `contexts/.ts` | Per-context `fetchXxx` bundles | +| `react.tsx` | `` provider + typed `use{Hook}()` hooks | +| `channels.ts` / `channels.hooks.tsx` | Channel types + hooks (when the schema carries channels) | +| `index.ts` | Stage 1 re-export root | ## Sub-exports | Import | When to use | |--------|------------| -| `@rythazhur/mizan` | Core: mizanProvider, hooks, forms, errors | +| `@rythazhur/mizan` | Core: `MizanProvider`, hooks, forms, errors | | `@rythazhur/mizan/channels` | WebSocket channels | | `@rythazhur/mizan/jwt` | JWT token management | | `@rythazhur/mizan/client` | HTTP clients (CSR/SSR) | diff --git a/frontends/mizan-react/package.json b/frontends/mizan-react/package.json index ab0beed..f4b41e9 100644 --- a/frontends/mizan-react/package.json +++ b/frontends/mizan-react/package.json @@ -17,10 +17,6 @@ "types": "./dist/client/react.d.ts", "import": "./dist/client/react.js" }, - "./client/nextjs": { - "types": "./dist/client/nextjs.d.ts", - "import": "./dist/client/nextjs.js" - }, "./channels": { "types": "./dist/channels/index.d.ts", "import": "./dist/channels/index.js" @@ -28,14 +24,6 @@ "./jwt": { "types": "./dist/jwt/index.d.ts", "import": "./dist/jwt/index.js" - }, - "./allauth": { - "types": "./dist/allauth/index.d.ts", - "import": "./dist/allauth/index.js" - }, - "./allauth/nextjs": { - "types": "./dist/allauth/nextjs.d.ts", - "import": "./dist/allauth/nextjs.js" } }, "scripts": { diff --git a/protocol/mizan-codegen/src/emit/python.rs b/protocol/mizan-codegen/src/emit/python.rs index b15e432..310fa28 100644 --- a/protocol/mizan-codegen/src/emit/python.rs +++ b/protocol/mizan-codegen/src/emit/python.rs @@ -8,7 +8,6 @@ use std::path::PathBuf; use askama::Template; -use indexmap::IndexMap; use crate::config::Config; use crate::emit::CodegenTarget; diff --git a/protocol/mizan-codegen/templates/react/react.tsx.j2 b/protocol/mizan-codegen/templates/react/react.tsx.j2 index 4a02567..339b53b 100644 --- a/protocol/mizan-codegen/templates/react/react.tsx.j2 +++ b/protocol/mizan-codegen/templates/react/react.tsx.j2 @@ -26,7 +26,7 @@ import { {% if has_contexts -%} registerContext, type ContextState, - {% endif -%} +{% endif -%} } from '@mizan/base' {% if !stage1_imports.is_empty() -%} diff --git a/protocol/mizan-codegen/tests/channels_smoke.rs b/protocol/mizan-codegen/tests/channels_smoke.rs index 0eb1941..4944ff6 100644 --- a/protocol/mizan-codegen/tests/channels_smoke.rs +++ b/protocol/mizan-codegen/tests/channels_smoke.rs @@ -18,7 +18,7 @@ fn fixture_config() -> Config { project_id: None, output: PathBuf::from("/tmp"), targets: vec!["channels".to_string()], - source: SourceConfig { fastapi: None, django: None }, + source: SourceConfig { fastapi: None, django: None, rust: None, script: None }, rust_kernel: None, rust_crate_name: None, } diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx b/protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx index 83a59e6..0f84021 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx +++ b/protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx @@ -14,10 +14,8 @@ import { } from 'react' import { configure, - initSession, mizanCall, mizanFetch, - MizanError, registerContext, type ContextState, } from '@mizan/base' @@ -45,6 +43,7 @@ function useContextSubscription( return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState) } + // Internal — wraps an imperative call() with isPending / error state. interface MutationHook { mutate: (args: TArgs) => Promise diff --git a/protocol/mizan-codegen/tests/python_parity.rs b/protocol/mizan-codegen/tests/python_parity.rs index f6aa93c..a58385e 100644 --- a/protocol/mizan-codegen/tests/python_parity.rs +++ b/protocol/mizan-codegen/tests/python_parity.rs @@ -20,7 +20,7 @@ fn fixture_config() -> Config { project_id: None, output: PathBuf::from("/tmp"), targets: vec!["python".to_string()], - source: SourceConfig { fastapi: None, django: None }, + source: SourceConfig { fastapi: None, django: None, rust: None, script: None }, rust_kernel: None, rust_crate_name: None, } diff --git a/protocol/mizan-codegen/tests/react_parity.rs b/protocol/mizan-codegen/tests/react_parity.rs index ba9584f..39627c8 100644 --- a/protocol/mizan-codegen/tests/react_parity.rs +++ b/protocol/mizan-codegen/tests/react_parity.rs @@ -19,7 +19,7 @@ fn fixture_config() -> Config { project_id: None, output: PathBuf::from("/tmp"), targets: vec!["react".to_string()], - source: SourceConfig { fastapi: None, django: None }, + source: SourceConfig { fastapi: None, django: None, rust: None, script: None }, rust_kernel: None, rust_crate_name: None, } diff --git a/protocol/mizan-codegen/tests/rust_parity.rs b/protocol/mizan-codegen/tests/rust_parity.rs index 3835196..c74588b 100644 --- a/protocol/mizan-codegen/tests/rust_parity.rs +++ b/protocol/mizan-codegen/tests/rust_parity.rs @@ -24,7 +24,7 @@ fn fixture_config() -> Config { project_id: None, output: PathBuf::from("/tmp"), targets: vec!["rust".to_string()], - source: SourceConfig { fastapi: None, django: None }, + source: SourceConfig { fastapi: None, django: None, rust: None, script: None }, rust_kernel: Some(RustKernelSpec::Path { path: "../../../frontends/mizan-rust".to_string(), }), diff --git a/protocol/mizan-codegen/tests/stage1_parity.rs b/protocol/mizan-codegen/tests/stage1_parity.rs index e179e2b..c0b1c18 100644 --- a/protocol/mizan-codegen/tests/stage1_parity.rs +++ b/protocol/mizan-codegen/tests/stage1_parity.rs @@ -30,7 +30,7 @@ fn synthetic_config() -> Config { project_id: None, output: PathBuf::from("/tmp"), targets: vec!["stage1".to_string()], - source: SourceConfig { fastapi: None, django: None }, + source: SourceConfig { fastapi: None, django: None, rust: None, script: None }, rust_kernel: None, rust_crate_name: None, } diff --git a/protocol/mizan-codegen/tests/vue_svelte_parity.rs b/protocol/mizan-codegen/tests/vue_svelte_parity.rs index 99de377..b5d9c58 100644 --- a/protocol/mizan-codegen/tests/vue_svelte_parity.rs +++ b/protocol/mizan-codegen/tests/vue_svelte_parity.rs @@ -20,7 +20,7 @@ fn fixture_config(target: &str) -> Config { project_id: None, output: PathBuf::from("/tmp"), targets: vec![target.to_string()], - source: SourceConfig { fastapi: None, django: None }, + source: SourceConfig { fastapi: None, django: None, rust: None, script: None }, rust_kernel: None, rust_crate_name: None, } diff --git a/workers/mizan-ssr/src/test-worker.tsx b/workers/mizan-ssr/src/test-worker.tsx deleted file mode 100644 index 7a71c08..0000000 --- a/workers/mizan-ssr/src/test-worker.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Test SSR worker — registers simple components for the test suite. - */ - -import { registerComponent } from './worker' - -// Simple component that renders props -function Hello({ name }: { name: string }) { - return
Hello, {name}!
-} - -// Component that renders a list -function UserProfile({ user_id, name }: { user_id: number; name: string }) { - return ( -
-

{name}

- ID: {user_id} -
- ) -} - -// Component that throws during render -function Broken() { - throw new Error('Intentional render error') -} - -registerComponent('Hello', Hello) -registerComponent('UserProfile', UserProfile) -registerComponent('Broken', Broken)