Compare commits

...

84 Commits

Author SHA1 Message Date
587be8c4ab SSR migrated to Rust 2026-06-04 21:37:24 -04:00
ae684a36cb Restore approved state (tree of 4effcc7 "Added LICENSE")
Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.

Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:59:53 -04:00
adcc027894 Deleted hallucination docs 2026-06-04 14:28:30 -04:00
6c5f6f1fba AFI parity: close all 35 gaps — every adapter wires every AFI-common capability
The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:44:35 -04:00
58d2cb2848 AFI parity: generate the matrix from conformance probes, not prose
The per-adapter parity table was hand-maintained prose. An adapter that
never wired a capability (FastAPI SSR, Axum WebSocket) got its gap
relabelled "Django-only" or "out of scope — use native equivalents," and
nothing went red. The de-scope was crystallized in five mutually-ratifying
sites: the README §Stack-extensions table, the AFI fixture docstring
("channels/forms/shapes aren't AFI-common"), the core registry's
extension-hook framing, the mizan-fastapi __init__ docstring, and a
"CSRF is Django-only" comment in two adapters' session endpoints.

Replace prose-parity with conformance-generated parity:

- tests/afi/manifest.py declares the AFI-common surface as data — one list
  of capabilities, one of adapters. Applicability ("—") is derived from
  transport, never typed.
- tests/afi/probes.py independently inspects each backend's source for the
  artifact a capability requires (comment-stripped, backend-scoped). Green
  means wired; a cell can't be set by editing a word.
- tests/afi/test_capability_parity.py asserts every (capability × applicable
  adapter) pair is wired. 35 unwired gaps are now loud red TFDD tests, each
  naming an owed binding. No xfail/skip.
- tests/afi/parity_table.py generates the README table from the probes;
  `make parity-check` fails CI on any hand-edit, like the codegen byte-parity.

Purge the five de-scope sites. The IR byte-parity gate is unchanged and green.
`make test-afi` is now intentionally red on the 35 gaps — that board is the
owed parity work, itemized; a gap turns green by being wired, never described.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:58:03 -04:00
b41f469bbd Corrected agent confabulations 2026-06-04 12:05:13 -04:00
66b2db81fb FastAPI and TypeScript improved 2026-06-04 05:14:29 -04:00
67ad91b673 Added file upload support 2026-06-04 04:20:05 -04:00
4effcc7597 Added LICENSE 2026-06-04 03:38:26 -04:00
776e0cf27a Added parity table. 2026-06-04 02:52:08 -04:00
ffdf9aa24d Cleaned dead code and updated documents 2026-06-04 02:42:13 -04:00
578e124d67 Latest states 2026-06-04 01:15:41 -04:00
a5ef93b879 mizan-webview-transport + webview-channels: VSCode webview as Mizan frontend
Two new frontend packages let a VSCode webview consume a Mizan backend
through its postMessage channel — peer transports to `mizan-tauri-transport`
and the default `httpTransport()`.

- `@mizan/webview-transport` implements `MizanTransport` (call/fetch)
  over postMessage with correlation-id pairing. Drop-in for `configure({
  transport: webviewTransport() })`; codegen output and React adapter
  are unchanged.

- `@mizan/webview-channels` mirrors mizan-react's WebSocket-based
  ChannelConnection — RPC + subscribe over the same postMessage channel
  for long-running ops where short request/reply isn't enough.

Both expect an extension-host-side dispatcher that reads envelopes via
`webview.onDidReceiveMessage` and routes them through mizan-ts's
`handleMutationCall` / `handleContextFetch`. First consumer is the
holomorphic VSCode extension.

mizan-codegen: new `[source.script]` generic source. Spawns an arbitrary
command and reads stdout as KDL IR. Keeps mizan-codegen out of the
business of knowing every possible backend language while preserving
the "subprocess emits KDL" contract every other source already follows.
Holomorphic uses it to invoke `python -m holomorphic.emit_ir` against
the mizan_core registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:51:12 -04:00
22dcf0e3c1 mizan-tauri + Pydantic-aware codegen: Tauri-as-Mizan-backend substrate
Tauri now joins FastAPI/Django/axum as a first-class Mizan backend. The
React frontend calls Mizan-registered functions through Tauri's IPC
with the same {result, invalidate, merge} envelope the HTTP path uses;
the schema flows Pydantic → decoru → Rust → KDL → TS in one
mizan-generate invocation.

New packages:
* backends/mizan-tauri — Tauri plugin exposing a single `mizan_invoke`
  command that routes through mizan-core's FUNCTIONS / CONTEXTS
  registries. No per-function tauri::command; the linkme slice IS the
  dispatch table.
* frontends/mizan-tauri-transport — TS package exporting
  tauriTransport() that wraps invoke('plugin:mizan|mizan_invoke', ...)
  and re-shapes errors into MizanError. Pairs with mizan-tauri.

@mizan/base — pluggable transport:
* Adds MizanTransport interface + transport config field.
* Existing fetch-based body factored into httpTransport() (default).
* mizanCall/mizanFetch delegate to config.transport; merge/invalidate
  side-effects stay in the kernel (transport-agnostic).
* Consumers swap via configure({ transport: tauriTransport() }).

mizan-codegen — Rust source + Pydantic pre-step:
* [source.rust] runs a Cargo bin (cargo run --bin <name>) and parses
  KDL from stdout. The bin uses mizan_core::build_ir() after
  force-linking the consumer's #[derive(Mizan)] / #[mizan::client]
  registrations.
* [source.rust.pydantic] is an optional pre-step that pipes an
  embedded Python bridge (scripts/run_decoru.py) to python and writes
  decoru-emitted Rust types into the consumer crate. The bridge
  auto-discovers BaseModel subclasses AND Enum subclasses
  (last-variant-is-default convention so decoru's impl Default keeps
  compiling against enum-typed fields without explicit Pydantic
  defaults).
* Pure-Rust usage stays intact — omit pydantic block and write Rust
  types by hand.

mizan-macros:
* #[mizan::client] now supports Result<T, MizanError> returns. The
  dispatch wrapper `?`-unwraps the user fn so server-side errors
  surface as the protocol's standard {code, message, details?}
  envelope; T-returning functions stay unchanged.
* #[derive(Mizan)] strips the r# raw-identifier prefix and honors
  field-level #[serde(rename = "...")] when emitting IR field names.
  Matches serde's wire shape — fixes IR-vs-JSON drift for Rust-keyword
  fields (e.g. `r#type` → `type`).

react.tsx template:
* Conditionally emits context-related imports / useContextSubscription
  helper based on has_global || !named_contexts.is_empty(). Consumers
  without contexts (mutation/RPC-only apps like claude-manage) no
  longer get dead imports that trip noUnusedLocals.

Verified end-to-end: cargo build clean across mizan-tauri,
mizan-codegen, AFI rust_app; AFI three-way KDL parity tests pass;
claude-manage migration drives the full stack (Pydantic schema →
generated TS api → Tauri-IPC transport → mizan-core dispatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:01:45 -04:00
54f060c273 mizan: IR inline-substitution + reachability tree-shake + serde rename_all
Three substrate moves required by the Blazr-session port that surfaced
real cross-backend divergences:

1. Inline-substitution for primitive aliases and string enums. Named
   types whose body is `Alias(Primitive(_))` or `Enum(_)` collapse into
   their inline TypeShape at every `Ref` use site, and don't emit as
   their own `type "X" { ... }` entry. Matches Python's Pydantic Literal
   and `Foo = str` alias inlining — codegen consumers see the primitive
   directly rather than chasing a single-hop indirection.

2. Reachability tree-shake on the type registry. `#[derive(Mizan)]` now
   auto-registers every Mizan type into the TYPES slice; the emitter
   then transitively walks Refs from function inputs/outputs and emits
   only the reachable subset. Original-named entries from derive
   register only when something refs them; canonical-renamed entries
   from the function macro are reachable by definition. Mirrors
   Python's `_collect_named_types`.

3. `#[serde(rename_all = "...")]` + `#[serde(rename = "...")]`
   propagation in `#[derive(Mizan)]` for enums. IR enum variants now
   match the on-wire JSON casing (lowercase / snake_case / kebab-case /
   etc.), not the Rust variant idents. Supports all serde casings.

AFI codegen + wire parity stays green after these changes (the AFI
fixture's enum-free + Pydantic-shape types are unchanged by the three
substrate extensions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:01:28 -04:00
a1d1d6928f mizan-axum + macros: state threading, array/map lowering, merge shape semantics
Three substrate extensions surfaced by the Blazr session port:

1. **App-state threading.** mizan-axum::router() is now generic over a
   user-supplied state type and threads `Arc<dyn Any + Send + Sync>` into
   every dispatch via RequestHandle. Handlers downcast to their concrete
   AppState. The stateless AFI fixture uses `router_stateless()` (matches
   the prior signature). RequestHandle gains a `from_dyn()` constructor
   to wrap already-erased trait-object references.

2. **`[T; N]` and `BTreeMap<K, V>` lowering in #[derive(Mizan)].** Fixed
   arrays emit as `List<T>` (matches Python `tuple[float,...]` → JSON
   array). String-keyed maps emit as `List<V>` — closest approximation
   until KDL grows a `dict` shape. Also: vec-element registrations get a
   per-function scope suffix so two handlers returning `Vec<Same>` don't
   collide at the static-name layer.

3. **`types_match` for merge: upsert-into-list semantics.** Now matches
   Python `types_match_for_merge`: direct (T == T), upsert (slot is
   `Alias(List(T))`, value is T), and list-replace (both sides list).
   The AFI fixture only exercised the direct path; the Blazr port's
   `morph_set_value` returning a single `MorphLayer` into a context with
   `Vec<MorphLayer>` slot is what surfaced the gap.

AFI codegen + wire parity stays 12/12 green after these substrate changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:40:33 -04:00
45bde51166 Mizan-Rust backend adapter: server-side substrate + three-way parity
Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:

    #[derive(Mizan, Serialize, Deserialize)]
    pub struct ProfileOutput { pub user_id: i64, pub name: String }

    #[mizan::context("user")]
    pub struct UserCtx;

    #[mizan::client(context = UserCtx)]
    pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }

…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.

New crates:
- cores/mizan-rust/         (Cargo: mizan-core)     — IR types, KDL emitter, traits, registry,
                                                       runtime (compute_invalidation / compute_merges
                                                       ported from mizan-fastapi), graph_check with
                                                       structural type-matching
- cores/mizan-rust-macros/  (Cargo: mizan-macros)   — #[derive(Mizan)], #[mizan::context],
                                                       #[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum)     — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/                                — AFI fixture port + server / export-ir binaries

Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
  now sort alphabetically in both Python and Rust emitters. The IR is a
  contract; linkme doesn't preserve declaration order, so canonical sort
  is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
  it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
  the #[mizan::client] macro registers canonical-named entries from
  fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
  by name — matches the Python types_match_for_merge semantics.

Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py    — 12/12 probes against FastAPI + Rust (EXIT=0)

Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
  mizan-django/setup/discovery.py (referenced a function that no longer
  exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
  probe parity (wire-parity harness now polls /api/mizan/session/ on both
  backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
  don't need per-crate gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:31:26 -04:00
9900f8a36f Mizan IR: cut over to KDL, delete OpenAPI envelope
Replaces the transitional OpenAPI 3.0 + `x-mizan-*` extensions
substrate with the canonical Mizan IR as KDL, per docs/AFI_ARCHITECTURE.md:
"KDL is the contract; everything else (REST envelopes, OpenAPI
documents, framework idioms) is sediment around it."

End-to-end cutover. No transitional path left on main.

Forward direction:
  cores/mizan-python/src/mizan_core/ir.py
    build_ir() walks mizan_core.registry, introspects Pydantic
    models directly (no JSON-Schema indirection), and emits the
    Mizan IR document. The KDL grammar is locked in this file's
    module docstring.

Backends emit KDL:
  backends/mizan-fastapi/src/mizan_fastapi/ir.py
    `python -m mizan_fastapi.ir <module>` — CLI entry point.
  backends/mizan-django/.../management/commands/export_mizan_ir.py
    `manage.py export_mizan_ir` — Django mgmt command.

Codegen consumes KDL:
  protocol/mizan-codegen/Cargo.toml: + kdl = "6"
  protocol/mizan-codegen/src/ir.rs: NamedType { Struct/List/Enum/Alias }
    + TypeShape { Primitive/Ref/List/Optional/Enum/Union } sum types,
    replacing the JsonSchema sprawl. KDL parser walks the
    `kdl::KdlDocument` tree into typed Rust structs.
  protocol/mizan-codegen/src/fetch.rs: subprocess command switches
    to the new IR-export entry points.
  All emit modules (stage1 / react / python / rust / vue / svelte /
    channels) port their type-walkers from JsonSchema to the new
    sum types — case analysis collapses substantially.

Substrate-honesty wins beyond the moat closure:
  - `int | bool` multi-arm unions land as `TypeShape::Union` (was
    silently coerced to "string" before).
  - `<CamelName>Output = list[T]` returns emit as named alias
    types instead of struct-shaped wrappers, so consumer code
    `.map()` works directly on the type.
  - Pydantic field defaults flow through to `default` properties
    in KDL, then back to non-optional shape in every target.

Deleted:
  - backends/mizan-fastapi/src/mizan_fastapi/{cli,schema}.py
  - backends/mizan-django/.../export_mizan_schema.py
  - openapi-bearing half of mizan/export/__init__.py (edge
    manifest generator preserved — separate concern).
  - tests/afi/schema_normalizer.py
  - tests/fixtures/{afi_schema.json, channels_schema.json}
  - tests/fixtures/js_* baseline directories.

Verification:
  - 20 mizan-codegen unit tests green (IR deserialization,
    byte-equivalence parity across stage1/rust/python/react/vue/svelte
    against fresh KDL-driven baselines, channels structural).
  - tests/rust/run_wire_parity.py: 12/12 probes green driving
    the binary end-to-end through KDL.
  - Blazr studio-ui typechecks against the regenerated React client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:14:47 -04:00
7fb0c4a400 Mutation→context merge primitive across the stack
The @client(merge=[context, ...]) decorator lets a mutation patch its
return value directly into the cached context bundle by matching the
mutation's Output type against each context-function's Output type
to identify the slot, then splicing server-side. Kernel runs
splice_slot on the response to apply locally — no refetch, no
invalidate-cascade.

Lands H14, H15, H16, M19, M20 from ISSUES.md.

Backends (Django + FastAPI):
  _resolve_merges() in both executors walks @client(merge=...) targets,
  resolves the per-context slot via types_match_for_merge, and emits
  {context, slot, value, params?} entries on the response. Param
  auto-scoping mirrors _resolve_invalidation's tier-1 logic.

Frontend kernel (mizan-base):
  Response handler reads the merge[] array and applies splice_slot
  for each entry — locates the cached context bundle by name+params,
  overwrites the named slot with the new value, notifies subscribers.

Core (mizan-python):
  @client decorator extended with merge= parameter. Schema export
  threads merge metadata onto the OpenAPI x-mizan-functions entries.

Examples / fixtures:
  fastapi-react-site harness exercises merge + Playwright spec covers
  the end-to-end happy path (mutation → instant UI update without
  network refetch). AFI fixture's rename_user function is the
  canonical merge target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:29:06 -04:00
43bcf3f26f Mizan codegen substrate: Rust kernel + Rust codegen binary, JS generator deleted
The Mizan codegen substrate moves off JavaScript template-literal emission
onto a compiled Rust binary that consumes the same OpenAPI + x-mizan-* IR
the JS substrate consumed. Three structural wins fall out of one move:

1. Moat closes. The codegen logic (how `affects` becomes auto-invalidation,
   how named contexts collapse onto bundled fetches, how the registry-to-
   Provider mapping is shaped) ships compiled instead of as source bytes
   in every consumer's node_modules.

2. Pattern F (lines.push append-walls) becomes structurally unauthorable.
   The emit substrate is askama templates in templates/<target>/*.j2 —
   actual target-language files with {{ ... }} substitution markers,
   syntax-highlighted natively, type-checked against the render context
   structs at compile time. The Rust emit modules build typed render
   contexts and call .render(); no string-builder surface exists.

3. OpenAPI `default`-bearing fields now emit as non-optional in TS / Python
   / Rust — the server always populates them, so consumer code reads them
   without nullable checks. Surfaced by Blazr's typecheck on regeneration.

Layout:
  frontends/mizan-rust/        — Rust port of @mizan/base; #[cfg(feature="pyo3")]
                                 exposes PyMizanClient for the Python target.
  protocol/mizan-codegen/      — codegen binary source + askama templates.
  protocol/mizan-generate/     — npm-package shim. bin/launcher.mjs dispatches
                                 to the platform-appropriate prebuilt binary.
                                 Old generator/ JS tree deleted.
  tests/rust/                  — wire-parity drivers. drive_kernel exercises
                                 raw client.call() / fetch_context(); drive_emitted
                                 exercises the typed crate the codegen emits.
  tests/afi/afi_codegen_app.py — codegen entrypoint module (imports + registers).
  backends/mizan-fastapi/.../schema.py — adds outputNullable so the Rust
                                 codegen can wrap T | None responses in Option<T>.

Verification:
  - 20 mizan-codegen tests green (IR deserialization, byte-equivalent
    parity vs JS baseline for stage1/rust/python/react/vue/svelte,
    structural test for channels).
  - tests/rust/run_wire_parity.py — 12/12 probes green via the Rust binary
    driving the FastAPI fixture end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:26:32 -04:00
c15c6f3e14 Clean codegen leftovers from mizan-react after the protocol/ relocation
The codegen used to live in mizan-react before mizan-django before
protocol/mizan-generate. Each move left sediment in the previous
home; the bin entry in particular shadowed mizan-generate's own bin
in node_modules/.bin/, breaking `npx mizan-generate`. Caught at
integration time when the harness install picked up the stale link.

frontends/mizan-react/package.json:
- Removed bin entry pointing at the long-gone ./dist/generator/cli.mjs.
- Simplified the build script — dropped `cpSync('src/generator',
  'dist/generator', ...)`. src/generator hasn't existed in this package
  since the first move; the cpSync would silently fail at every build.
- Removed optionalDependencies (chokidar, minimatch, openapi-typescript) —
  these were codegen-watcher deps, no longer relevant to the React adapter.

examples/{django,fastapi}-react-site/harness/package.json:
- Added `mizan-generate` as a file: devDep so `npx mizan-generate
  --config <config.mjs>` resolves to the right binary in the monorepo.
  Mirrors the install pattern the README documents for downstream users.

Verified: mizan-react vitest 33/33 (78 skipped — integration tests).
Codegen runs from harness via npx for both example apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:24:30 -04:00
cc887fb1f6 Move codegen out of mizan-django: protocol/mizan-generate/
The codegen consumes a schema from any backend and emits typed client
code for any frontend — it doesn't belong inside a backend adapter.
That placement was historical sediment from when there was only a
Django backend; it predates the AFI generalization.

New top-level slot: `protocol/` for protocol-level tooling. Tree is
now:

  backends/    server protocol adapters
  frontends/   client kernel + per-framework adapters
  cores/       shared language-level primitives
  protocol/    protocol-level tooling
  workers/     runtime workers / bridges

Codegen moves to `protocol/mizan-generate/`. Same file layout under
`generator/` (cli.mjs, lib/), preserved via git mv.

Package metadata cleaned up:
- name: "generate" (placeholder) → "mizan-generate"
- description filled in
- type: module (cli.mjs is .mjs ESM, was previously declared "commonjs")
- bin entry added so `npx mizan-generate --config <config.mjs>` works
  once the package is published, instead of `node path/to/cli.mjs`.

Path-reference fixups:
- backends/mizan-django/README.md: `node path/to/...` → `npx mizan-generate`
- backends/mizan-fastapi/README.md: same
- ISSUES.md: file paths in three issue entries
- CLAUDE.md: codegen description + Package Layout section refreshed
  (added protocol/, mizan-fastapi entry, mizan-python entry)
- docs/AFI_ARCHITECTURE.md: Package Layout refreshed identically

Verified codegen runs from new location: regenerated the FastAPI
example harness's api/ output, identical to pre-move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:16:11 -04:00
f0f7a93ed2 Backend adapter READMEs — DX surface + codegen invocation
mizan-django/README.md:
- Updated install path (was pointing at the old `subdirectory=django` git
  layout from before the backends/ restructure).
- Dropped the dead "monorepo root README" link (the root README was
  removed earlier in the substrate-restoration work).
- Fixed the apps.py example — convention is `clients.py` per MIZAN.md,
  not `mizan_clients.py`.
- Added the `mizan_clients()` auto-discovery pattern (it was missing).
- Added a Generate-the-frontend section: config shape + CLI invocation
  + the resulting <MizanContext>/use{Hook}() React surface.
- Tightened decorator-parameter overview to a single block covering the
  full @client surface.

mizan-fastapi/README.md (new):
- Mirrors mizan-django's structure for consistency.
- Opens with the AFI-common scope: forms/channels/shapes/SSR are out of
  scope on the FastAPI side; FastAPI projects use native equivalents.
- Setup shows app.add_exception_handler wiring for MizanError +
  RequestValidationError so every error surface goes through the same
  envelope the kernel parses.
- Calls out explicit register() (no AppConfig.ready() analog on FastAPI;
  registrations live in main.py or an imported clients.py).
- Auth-integration section explains the request.state.user middleware
  contract the executor expects.
- Codegen section shows the source.fastapi config shape that points at
  the new `python -m mizan_fastapi.cli <module>` schema export.
- Closes with pointers to AFI conformance + the e2e harness so a reader
  can verify the adapter's claims.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:10:01 -04:00
255e10cb21 mizan-fastapi e2e — example app + Playwright harness, 14/14 green
Demonstration milestone. The substrate work earlier in the session
established that mizan-fastapi can dispatch RPC, bundle context
fetches, and emit invalidation envelopes via TestClient (in-process
ASGI). This commit closes the demonstration gap: a real FastAPI server
on port 8001 + a real React harness on port 5175 + Playwright in real
Chromium, exercising generated hooks.

What ships:

backends/mizan-fastapi/src/mizan_fastapi/cli.py — schema-export CLI:
- `python -m mizan_fastapi.cli <module>` imports the named module
  (triggering @client decorations + register() side effects), then
  prints the OpenAPI schema to stdout. Mirrors mizan-django's
  `manage.py export_mizan_schema` so the codegen consumes either
  backend the same subprocess way.

backends/mizan-django/generate/generator/lib/fetch.mjs — codegen now
dispatches on source.django vs source.fastapi. Refactored the
subprocess plumbing into a shared runSubprocess helper. The codegen
package is still named "mizan-django" by historical accident — it's
the framework-agnostic CLI now (a rename for later).

backends/mizan-fastapi/src/mizan_fastapi/executor.py — bug fix:
mizan_core's @client decorator normalizes auth=True to
meta['auth']='required'. The executor's match was only handling True,
not 'required', so any auth-required endpoint failed with
INTERNAL_ERROR. Now matches both. Caught when wiring up the FastAPI
example backend's whoami fixture; would have surfaced first time any
real FastAPI app used auth=True.

backends/mizan-fastapi/tests/test_dispatch.py — added AuthTests
covering the auth=True path so the bug fix has unit coverage. Suite
now 12/12.

examples/fastapi-react-site/ — parallel to examples/django-react-site/:
- backend/main.py: FastAPI app with 11 @client fixtures matching the
  harness surface (echo, add, multiply, whoami, staff/superuser/
  verified-only, notImplementedFn, buggyFn, permissionCheckFn,
  current_user context). Drops Django-only stuff (forms, channels,
  ws-whoami, session-bound JWT).
- harness/: vite proxy → FastAPI on 8001; generated api/ produced by
  the codegen against fastapi.config.mjs.
- mizan.spec.ts: Playwright suite, 14 tests covering the same axes
  as Django minus channel-chat.
- ContextCurrentUser fixture renders 'loading' until data arrives
  rather than emitting <pre>null</pre> — fixes a race the Django
  harness has too (just doesn't trip in practice).

Verified:
- mizan-fastapi unit:    12/12 (incl. new auth=True coverage)
- mizan-fastapi e2e:     14/14 (Playwright via real Chromium)
- mizan-core unit:       15/15
- mizan-django unit:     348 pass, 21 skip
- AFI conformance:        3/3
- mizan-django e2e:      14/15 (1 skip — channels, deferred)

What remains for FastAPI side:
- Dockerfile.test + docker-compose.test.yml so CI can run the e2e
  in the same containerized way as the Django example.
- Makefile test-integration target for symmetry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:05:18 -04:00
19ce4d4a2a Restore KDL-as-IR to AFI_ARCHITECTURE
Re-stating the architectural intent that got silently swapped to "JSON
schema" during the doc-restoration commit (6eca514). The original
AFI_ARCHITECTURE positioned KDL as the LLVM-IR-equivalent of the
system; I read the stale-package-name note at the bottom as
invalidating the whole architecture description and substituted
JSON-schema references thinking that was the more-current intent.

That conflated implementation drift (mizan-ast/mizan-schema package
names) with substrate intent (KDL is the IR). The packages had
drifted; the IR target had not. Restoring the section.

Frames KDL-as-IR explicitly as forward-direction. The current
OpenAPI/JSON-Schema codegen path is transitional — it's the layered
indirection where adapter divergence lives (AFI conformance suite
demonstrates this). Real KDL implementation lives in a forthcoming
mizan-schema package; today's path is sediment around the eventual
substrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:19:40 -04:00
0a95f3c860 AFI conformance test suite
Substrate-level gate: same @client fixture registered in both backends
emits equivalent schemas, therefore the codegen produces equivalent
TypeScript regardless of which backend the frontend is generated against.
Catches adapter symmetry problems (Pydantic→OpenAPI converter divergence,
metadata leakage, ordering non-determinism) without docker, browser, or
Playwright.

What ships:

backends/mizan-fastapi/src/mizan_fastapi/schema.py — build_schema():
- Builds OpenAPI 3.0 from registered Mizan functions, mirroring the
  shape mizan-django's export emits.
- Drives FastAPI's native OpenAPI generation by registering a stub POST
  endpoint per function with its Input/Output Pydantic models, then
  appends x-mizan-functions and x-mizan-contexts extensions.
- Param-elevation logic mirrors mizan-django/src/mizan/export/__init__.py
  exactly (sharedBy tracking, required iff every function in context has
  the param).
- snake_to_camel and metadata field shapes match Django for byte-equality
  on the AFI surface.

tests/afi/ — the conformance harness:
- fixture.py: 5 @client functions covering the protocol axes (plain,
  context, mutation+affects). No channels/forms — those aren't AFI-common.
- django_app/: minimal Django project (settings, urls, AppConfig.ready
  registers the fixture). manage.py adds tests/afi/ to sys.path so both
  backends import the same fixture module.
- fastapi_app.py: thin make_app() that registers fixture and mounts router.
- schema_normalizer.py: drops backend-specific framing — Ninja-vs-FastAPI
  envelope differences (info/servers/tags), Django-only function fields
  (form metadata), x-mizan-channels. Plus afi_subset() and
  function_io_schemas() helpers for narrower comparisons.
- test_codegen_parity.py: three gates
  1. x-mizan-functions match across backends
  2. x-mizan-contexts match across backends
  3. Per-function Input/Output OpenAPI schemas match (what codegen feeds
     to openapi-typescript for type generation)

The full normalized OpenAPI envelopes do diverge — FastAPI adds
HTTPValidationError, the two converters wrap things slightly differently
in non-AFI-essential ways. That's not in the test scope. The codegen
only consumes x-mizan-functions, x-mizan-contexts, and the per-function
type schemas; those are what the test gates.

Makefile: test-afi target added; rolls into the test aggregate.

Verified: 3/3 conformance tests pass. Other surfaces unaffected —
mizan-core 15/15, mizan-django 348 pass, mizan-fastapi 11/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:59:01 -04:00
aaaf80cdbf End-to-end: harness Playwright suite green (14 pass, 1 skip)
After the React-codegen rework, ran the full e2e harness against the
docker-stack backend. Surfaced and fixed real friction:

mizan-base/src/index.ts (kernel):
- MizanError now parses both error envelopes — the FastAPI shape
  ({"error": {"code", "message", "details"}}) and the Django shape
  ({"error": true, "code", "message", "details"}). Exposes .code and
  .details on the thrown error so consumer code can branch on them.
  This was needed for the harness's `instanceof MizanError && error.code
  === 'NOT_FOUND'` pattern to work; the previous MizanError only carried
  status + raw body, leaving callers to parse the body themselves.

examples/django-react-site/Dockerfile.test:
- Backend image now copies and installs cores/mizan-python before
  installing mizan-django (which imports from mizan_core after the
  Layer 1 extraction).

harness/src/fixtures.tsx:
- useRun helper updated for the new mutation-hook shape: pulls
  { mutate } off the hook result instead of treating the hook return
  as a callable. Same for ValidationError fixture.

mizan.spec.ts:
- DjangoError → MizanError (kernel error class is backend-agnostic).
- Form tests removed (forms codegen deferred per Blazr scope).
- Channel test marked test.skip (channels deferred per Blazr scope).

.gitignore: ignore Playwright test-results/.

Final verification across all surfaces:
- mizan-core unit:       15/15
- mizan-django unit:     348 pass, 21 skip
- mizan-fastapi unit:    11/11
- mizan-ts edge-compat:  34/34 (cross-language HMAC pin)
- harness e2e (Playwright): 14/15 (1 skip = channels deferred)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:38:52 -04:00
2982741aad React wrapper-layer codegen — restore the idioms over the kernel
The harness was written against the MIZAN.md oracle (<MizanContext>,
provider-per-context, useMizan, etc.) but the codegen had been narrowed
to just hooks-direct-on-kernel after the kernel split. Restoring the
React-idiomatic layer on top of the kernel.

backends/mizan-django/generate/generator/lib/adapters/react.mjs:
- Emits <MizanContext baseUrl="…"> root provider that calls configure()
  once and (if a global context is registered) wraps children in
  <GlobalContextProvider>.
- Emits <GlobalContextProvider> + <{Name}Context> per named context —
  kernel registration happens once per provider mount, not per hook
  call. Consumers read from React Context.
- Base hooks: useGlobalContext() / use{Name}Context() return full
  ContextState<T> (data + status + error).
- Convenience hooks per context-function (use{Fn}() returns data | null)
  and per regular function/mutation (use{Fn}() returns
  { mutate, isPending, error }).
- useMizan() returns { call, fetch } as an imperative escape hatch
  for test harnesses or rare cases where typed hooks don't fit.
- Re-exports MizanError, configure, initSession, ContextState from
  @mizan/base.

backends/mizan-django/generate/generator/cli.mjs:
- After Stage 2, appends `export * from './<adapter>'` to index.ts so
  `import { useEcho, MizanContext } from './api'` works as a barrel.

Bug fixes surfaced during integration:
- react.mjs was generating `from '../index'` (wrong path); flat layout
  needs `./index`.
- harness django.config.mjs had `output: 'src/api/generated.ts'` which
  the codegen treated as a directory; corrected to `output: 'src/api'`.
- example testapp/clients.py imported from the deleted
  mizan.setup.registry path; routed through mizan.setup aggregator.

harness/package.json: adds @mizan/base dep so the generated react.tsx
can resolve its kernel imports.

harness/src/fixtures.tsx:
- DjangoError → MizanError (kernel error class, backend-agnostic).
- useChatChannel sourced from ./api/channels.hooks directly (not
  re-exported from the unified index for now).
- Form fixtures removed — forms codegen deferred per Blazr scope.

Verified: harness `vite build` succeeds, 53 modules transformed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:21:49 -04:00
63c9a9c4ce mizan-fastapi: more Pythonic and declarative
Reworked the MVP code along the lines Ryth flagged. Same behavior
(11/11 tests still pass), tighter idiom.

executor.py:
- Replaced FunctionResult / FunctionError dataclasses with a MizanError
  exception hierarchy (NotFound, BadRequest, ValidationFailed,
  Unauthorized, Forbidden, NotImplementedYet, InternalError). Each
  carries its own ErrorCode + HTTP status; the dispatcher path raises
  rather than returning sentinel objects.
- Auth check uses match/case for the requirement (True / 'staff' /
  'superuser' / callable / other) — single declarative dispatch instead
  of an if/elif chain.
- Broke up the single 80-line execute_function into focused helpers:
  _resolve_function, _enforce_auth, _validate_input, _serialize,
  _invalidation_target. The execute_function body now reads as five
  declarative steps.
- Input validation uses Pydantic's model_fields[name].is_required()
  directly and a list comprehension for required-field reporting,
  instead of round-tripping through model_json_schema().

router.py:
- POST /call/ now declares its body as a Pydantic CallBody model;
  FastAPI handles parsing + envelope validation. No more manual
  await request.json() + dict[get] dancing.
- Endpoint bodies shrink to 3-5 lines each. Context fetch uses a
  dict comprehension over the function group.
- mizan_exception_handler renders MizanError to the protocol's
  {error: {code, message, details}} envelope.
- mizan_validation_handler maps FastAPI's RequestValidationError to
  the same envelope under BAD_REQUEST so the wire format is uniform
  whether the failure is body-shape or business validation.

__init__.py: exposes the full exception hierarchy + both handlers
so consumers can wire them onto their FastAPI app declaratively:

    app.add_exception_handler(MizanError, mizan_exception_handler)
    app.add_exception_handler(RequestValidationError, mizan_validation_handler)

Verified: mizan-core 15/15, mizan-django 348 pass, mizan-fastapi 11/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:51:03 -04:00
4e4d1bb6b1 Build mizan-fastapi MVP — HTTP RPC + context bundling
The Blazr-critical surface for FastAPI. Forms, Channels, Shapes, SSR,
and MWT are out of scope (Ryth's call: defer until Blazr exercises
them; FastAPI projects use native equivalents anyway).

What ships:
- POST /api/mizan/call/      RPC dispatch with Pydantic input validation
- GET  /api/mizan/ctx/{name}/ bundled context fetch (all functions in
                              the named context, parallel-evaluated, single
                              JSON response)
- JSON-body invalidation transport (the 'invalidate' field on mutation
  responses, with auto-scoping when mutation arg names match context params)
- Auth check infrastructure expecting request.state.user populated by
  FastAPI middleware/deps (matches FastAPI idioms)
- Cache-Control: no-store on all responses

Built on existing mizan-core: registry (function lookup, context groups,
invalidation metadata), client.function (the @client decorator + ServerFunction
+ _FunctionWrapper). No code copied or duplicated from mizan-django — the
shared substrate is genuinely shared.

Package layout:
  backends/mizan-fastapi/
    pyproject.toml         distribution=mizan-fastapi, module=mizan_fastapi
    src/mizan_fastapi/
      executor.py          dispatch + auth + invalidation
      router.py            FastAPI APIRouter with the two endpoints
    tests/test_dispatch.py 11 e2e tests against TestClient

Test fixture establishes the registration pattern: explicit
register(fn_class, "name") after each @client. mizan-fastapi doesn't
ship discovery — apps register their functions explicitly. (mizan-django
keeps its DjangoAppVisitor discovery; FastAPI's lack of an app system
makes auto-discovery less natural.)

Makefile: install + test targets now include mizan-fastapi alongside
the other packages. New test-core / test-fastapi targets added for
symmetry.

Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-fastapi: 11/11
- mizan-ts edge-compat: 34/34

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:39:19 -04:00
dd41f0c25f Extract client/function.py to mizan-core (Tier B)
The @client decorator + ServerFunction base + composition machinery is
mostly framework-agnostic. The only Django couplings were typing
(HttpRequest in __init__ and submit_handler signatures) and runtime
view-path detection (HttpResponseBase isinstance/issubclass checks).

Replaced both with backend-extension hooks:

- HttpRequest type hints → Any. Type Protocol can be tightened later.
- HttpResponseBase view-path detection → set_framework_response_base(cls)
  hook in mizan_core.client.function. Backends register their framework's
  response base at import time. is_framework_response(obj_or_cls) handles
  both instance and subclass checks via the registered base.

mizan-django registers HttpResponseBase via mizan/client/__init__.py
before any @client-decorated code is loaded. FastAPI would similarly
register starlette.responses.Response.

Direct consumers updated:
- mizan/setup/discovery.py: ServerFunction import path
- mizan/forms/__init__.py: ServerFunction + create_form_functions imports

mizan/client/__init__.py keeps its public re-export surface stable so
'from mizan.client import client, ServerFunction, …' continues to work
for downstream Django consumers.

Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts edge-compat: 34/34

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:39:48 -04:00
76fce2dc85 Split the registry — function/composition core + backend extensions
The original registry tangled function, channel, composition, and form
registration in a single file with polymorphic register() dispatch.
That predates the household discipline; it was the design that was
supposed to ship but didn't. Re-implementing the original intent.

cores/mizan-python/src/mizan_core/registry.py (new):
- _functions, _compositions dicts
- register() — ServerFunction-only, no polymorphic dispatch
- register_as(), register_compose()
- register_extension(name, extension) — hook interface
- get_function/get_compose/get_all_functions/get_all_compositions
- get_contexts, get_context_groups
- get_registry, get_schema — aggregate extension contributions
- validate_registry, clear_registry — cascade-clear extensions

RegistryExtension Protocol:
- schema() returns the extension's schema subdict (keyed under its name)
- clear() resets extension state (called by clear_registry)

mizan-django/src/mizan/channels/__init__.py:
- _ChannelsExtension wraps the channel _registry, plugs into core via
  register_extension('channels', ...). Schema output preserves the
  same shape codegen consumed before (snake_case keys, type+bidirectional).

mizan-django/src/mizan/forms/__init__.py:
- register_form() and get_forms() helpers moved here (were in setup/registry.py)
- Both use mizan_core.registry under the hood. Forms don't need a
  separate extension because form sub-functions register as regular
  ServerFunctions with meta.form set.

mizan-django/src/mizan/setup/registry.py: deleted.
mizan-django/src/mizan/setup/__init__.py: re-exports the registry helpers
from mizan_core.registry / mizan.channels / mizan.forms — the Django
adapter's curated public API surface stays stable for users.

Consumers updated: ~10 files imported `from mizan.setup.registry`;
all switched to direct imports from mizan_core.registry, mizan.channels,
or mizan.forms as appropriate. ChannelTests in test_core.py rewritten
to use mizan.channels.register directly (no more polymorphic
@register_as on ReactChannel subclasses).

Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts edge-compat: 34/34 (cross-language pin holds)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:21:16 -04:00
9150cdc5ee Extract cache/backend.py to mizan-core (Tier A)
cache/backend.py is pure framework-agnostic key-value abstraction —
CacheBackend Protocol, MemoryCache, RedisCache. No Django imports.
Moves to cores/mizan-python/src/mizan_core/cache/backend.py with no
content changes; mizan-django re-imports.

Verified: mizan-core 15/15, mizan-django 348 pass / 21 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:23:01 -04:00
37e61c646b Extract Layer 1 to cores/mizan-python (mizan-core)
Pull cache/keys.py (HMAC cache key derivation) and mwt.py (Mizan Web Token)
out of backends/mizan-django and into a new cores/mizan-python package.
mizan-django re-imports them via the new mizan_core module.

Naming: directory cores/mizan-python/, distribution mizan-core, importable
module mizan_core. mizan-django keeps its existing 'mizan' distribution slot
on PyPI; the two coexist as distinct packages.

Wiring:
- backends/mizan-django/pyproject.toml gains a 'mizan-core' dep with a
  [tool.uv.sources] path entry (editable install from ../../cores/mizan-python).
- Makefile install target prepends 'cd cores/mizan-python && uv pip install -e .'
- 3 import sites in mizan-django updated: cache/__init__.py, jwt/functions.py,
  client/executor.py — all now import from mizan_core.

Test split:
- 3 unit-test classes (CacheKeyDerivationTests, MWTCreationTests,
  PermissionKeyTests) move to cores/mizan-python/tests/, rewritten against
  unittest.TestCase (no Django dep). The cross-language pin test (pinned
  HMAC hex digests against mizan-ts) moves with CacheKeyDerivationTests.
- Integration tests stay in mizan-django (CacheBackendTests, CachePurgeTests,
  CacheIntegrationTests, RevParameterTests, MWTAuthIntegrationTests) — they
  need the Django request flow.

Verified:
- mizan-core: 15/15 pass (incl. cross-language pin)
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts: edge-compat 34/34 pass — protocol invariant holds, the moved
  Python derive_cache_key still produces the exact hex digests TS pins against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 01:32:54 -04:00
9d2781b52c Catch missed content edits from tree restructure
The fe39fcb commit captured the file moves (git mv stages those automatically)
but didn't catch the content edits I made afterward — npm package rename
(@mizan/runtime → @mizan/base), path updates in Makefile/Dockerfile/examples,
and doc updates were all left unstaged at commit time.

This commit lands those:
- npm rename: 3 frontend package.jsons (base/vue/svelte) + mizan-base/src/index.ts + 4 codegen templates
- path updates: Makefile, Dockerfile.test, two Gitea workflows, four example/harness configs
- doc updates: CLAUDE.md, ROADMAP.md, ISSUES.md, docs/AFI_ARCHITECTURE.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 01:26:04 -04:00
fe39fcb229 Restructure tree by role; rename mizan-runtime → mizan-base
packages/ flattens into:
  backends/   server protocol adapters (mizan-django, mizan-ts)
  frontends/  client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte)
  workers/    runtime workers (mizan-ssr)
  cores/      shared language-level primitives (empty for now; mizan-python forthcoming)

The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is
renamed to reflect its role — it's the shared base that frontend adapters
depend on directly. Reflects the substrate position that per-framework adapters
wrap a single shared kernel; codegen targets the adapter, not the raw kernel.

Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four
example/harness config files, .claude/settings.local.json, four docs
(CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 +
react/vue/svelte adapters), and three package.jsons (the mizan-base rename
plus mizan-vue/svelte peerDeps).

Generated files under examples/django-react-site/harness/src/api/ still
reference @mizan/runtime — left as-is; they're regenerated artifacts and
the harness is non-functional pending the React wrapper-layer codegen.

Also folded in a pre-existing fix: the Gitea workflows had
working-directory: react / django pointing at a layout that predates
packages/, never updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:55:37 -04:00
6eca514777 Restore documentation layer — match current substrate
ROADMAP: done items moved out of "Next" (codegen rewrite, SSR bridge,
edge manifest, X-Mizan-Invalidate, return-type branching, affects_params,
kernel extraction, two-stage codegen, mizan-ts). Real "Next" in:
framework-adapter wrapper layer (MizanContext + useMizan + DjangoError
on top of the kernel) for React/Vue/Svelte; A1–A4 from ISSUES.md.

CLAUDE: 4-package layout replaced with the actual 7-package layered
architecture (backend protocol adapters + frontend kernel + framework
adapters + SSR worker). "STALE codegen" section rewritten to describe
what's emitted vs. the wrapper layer that isn't yet.

docs/ now tracked (6 files). AFI_ARCHITECTURE rewritten — replaced
the speculative `mizan-ast`/`mizan-csr`/`mizan-rpc`/`mizan-schema`
package names with the real layout, dropped KDL-schema language for
the actual schema-export format. The other 5 docs/ files were already
current and are tracked as-is.

ARCHITECTURE-REWORK.md deleted — same expert review is re-tracked in
the fresher ISSUES.md, two parallel trackers was sediment.

README.md deleted — drift was beyond surgical fixes (`mizan_clients.py`
convention, `<DjangoContext>` provider, removed `@compose` and
`context='local'`, wrong codegen output filenames, 3-package structure
vs. 7). Rewrite waits for the wrapper-layer codegen to land so
user-facing examples reflect reality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:10:12 -04:00
5c1c583164 Fix example backend asgi.py — import testapp.clients (was testapp.mizan_clients)
Discovery convention per MIZAN.md is `clients.py`. The example backend's
asgi.py was still importing the older `mizan_clients` name, causing the
example Django container to fail to start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:09:55 -04:00
2d7cf3eb39 Document architectural debt and test coverage gaps in ISSUES.md
Added 6 architectural/cleanup items (A1-A6):
- Legacy MizanProvider not yet removed
- Allauth pending extraction to own package
- Forms codegen not adapted to kernel
- Vue/Svelte adapters not validated end-to-end
- ROADMAP.md and CLAUDE.md likely stale

Added 12 test coverage gaps (T1-T12):
- No tests for C6 kernel state machine
- No tests for generated Vue/Svelte output
- No tests verifying recent fixes (C3/C4/C5/C7/H3/H10/H11/H13)
- No end-to-end integration test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:12:02 -04:00
bb88fd984b C6: Runtime kernel owns data, status, error — adapters subscribe
The kernel is no longer a blind refetch pipe. Each context entry has:
  { data, status: idle|loading|success|error, error }

registerContext() returns { getState, subscribe, refetch, unregister }.
Adapters subscribe to state changes via callbacks. The kernel does
the fetch and notifies subscribers with the new state.

React adapter uses useSyncExternalStore for tear-free reads.
Vue adapter uses ref + subscribe callback.
Svelte adapter uses readable store backed by kernel subscription.

All three adapters also get:
- Mutation hooks with { mutate, isPending, error } (fixes H5)
- Vue: onServerPrefetch for Nuxt SSR (fixes M9)
- Svelte: readable store auto-cleans up on unsubscribe (fixes H9)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:38:53 -04:00
07f1c7842c Update ISSUES.md — 16 fixed, 22 remaining
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:30:52 -04:00
9c837cf285 Fix H3, H6, H11, H13, M11, M18 — quick wins from expert review
H3: mizanFetch retries 2x on server errors (5xx) and network
failures. 200ms/400ms backoff. Mutations NOT retried (not idempotent).

H6: refreshContext now uses GET /ctx/<name>/ instead of POST /call/.
Context reads go to the context endpoint, not the mutation endpoint.

H11: Python cache key derivation normalizes True→"true",
False→"false", None→"null" for cross-language HMAC consistency
with JavaScript's String() behavior.

H13: Forms isValid now checks that all required fields have been
touched, not just that touched fields have no errors.

M11: execute_function return type updated to include HttpResponseBase
for view-path functions.

M18: registerContext cleanup uses ?. instead of ! to prevent crash
if Map was cleared (already fixed in H2 commit but documenting).

373 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:30:10 -04:00
cdd15b3810 Fix critical issues C1-C5, C7, H1, H2, H4, H10
C1+C7: Cache purge now passes user_id and works for view-path
mutations. Extracted _purge_cache_for_invalidation() shared helper
used by both RPC and view-path branches.

C2: initSession retries 3x with backoff. Resets on total failure
so next call tries again instead of permanently broken CSRF.

C3: SSR template backend injects __MIZAN_SSR_DATA__ script tag
with serialized props for client-side hydration.

C4: SSR bridge uses _write_lock to serialize stdin writes from
concurrent Django threads. Prevents JSON interleaving.

C5: SSR bridge registers atexit handler for process cleanup.
No more orphaned Bun processes on Django reload/shutdown.

H1: pendingScoped changed from Map to Array — multiple scoped
invalidations for the same context no longer overwrite.

H2: registerContext uses stableKey() (sorted JSON) instead of
bare JSON.stringify. Property order no longer matters.

H4: Named context providers skip refetch if SSR data exists
(matches global context behavior).

H10: _meta always assigned as fresh dict, preventing shared-dict
mutation across ServerFunction subclasses.

373 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:24:41 -04:00
499aa0e038 Add ISSUES.md — expert review findings across 7 domains
7 critical, 13 high, 18 medium issues identified by:
Cloudflare, Serverless, Vercel, React Query, Django, Laravel, Vue/Svelte

Critical: scoped cache purge broken, initSession swallows errors,
SSR hydration never injected, SSR bridge thread-unsafe + leaks
processes, no loading/error states in kernel, view-path mutations
skip cache purge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:18:57 -04:00
c20de182e1 Two-stage codegen: React + Vue + Svelte from one schema
Stage 1 (framework-agnostic):
  types.ts           — OpenAPI interfaces
  contexts/<name>.ts — fetchXxxContext(params) using mizanFetch
  mutations/<name>.ts — callXxx(args) using mizanCall
  functions/<name>.ts — callXxx(args) using mizanCall
  index.ts           — re-exports

Stage 2 (per framework):
  react.tsx  — hooks + context providers + SSR hydration
  vue.ts     — composables with provide/inject + ref/computed
  svelte.ts  — writable/derived store factories

New packages:
  mizan-runtime — the kernel (~200 lines, zero framework deps)
    configure(), initSession(), registerContext(), invalidate(),
    mizanFetch(), mizanCall(), MizanError
  mizan-vue     — Vue adapter (package.json, codegen template)
  mizan-svelte  — Svelte adapter (package.json, codegen template)

CLI: mizan-generate --target react,vue,svelte
Config: target: 'react' (default) in django.config.mjs

Verified: codegen produces 33 functions across 2 contexts,
14 plain functions, 0 mutations, generating all three Stage 2
outputs from one schema fetch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:09:35 -04:00
6108845d99 Rename all djarea references to mizan
- djarea_clients.py → clients.py (both example apps)
- export_djarea_schema → export_mizan_schema (management command)
- djarea.spec.ts → mizan.spec.ts (playwright test)
- fetch.mjs: command name updated
- apps.py/asgi.py: import paths updated
- Removed stale generated.djarea.* artifacts
- Fixed desktop app: asgi.py import, vite config aliases, package.json dep path

373 Django tests pass. Both example apps verified running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:26:19 -04:00
1c6d9075ad Remove all Django-specific naming from mizan-react
Renamed:
  DjangoError         → MizanError
  DjangoHTTPClient    → MizanHTTPClient
  DjangoFormState     → MizanFormState
  DjangoFormsetState  → MizanFormsetState
  createDjangoCSRClient → createMizanCSRClient
  createDjangoSSRClient → createMizanSSRClient
  ensureDjangoSession → ensureMizanSession
  useDjangoCSRClient  → useMizanCSRClient
  TDjangoMessage      → TServerMessage

Made CSRF configurable:
  configureCsrf(cookieName, headerName) — defaults to Django
  conventions but works with any backend that uses CSRF tokens.
  Cookie name and header name are no longer hardcoded.

All old names preserved as deprecated aliases in index.ts exports
for backwards compatibility.

Removed dead RouterAdapter re-export (file moved to legacy/).

33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:55:24 -04:00
27c30d7e50 Move allauth + auth UI to legacy/
allauth/ (44 files) is a django-allauth React UI — a separate concern
from the Mizan protocol. Moved to legacy/ pending extraction into a
standalone mizan-django-allauth package.

Also moved to legacy/:
- client/AuthContext.tsx — generic auth state from /me endpoint
- client/RouterContext.tsx — framework-agnostic router adapter
- client/routing.tsx — UserRoute/StaffRoute/AnonymousRoute guards
- client/nextjs.tsx — Next.js router adapter for auth

These are auth UI infrastructure, not Mizan protocol. The Mizan core
only needs JWT for auth header selection (jwt/ stays — MizanProvider
depends on useJWT() to decide between Bearer and session auth).

Cleaned up re-exports in client/react.ts and vitest aliases.

33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:41:22 -04:00
24ff0ae66d Cleanup: delete dead code, fix invalidateFunctions bug, deduplicate
Deleted:
- runtime/index.ts (146 lines) — never imported by anything
- httpFunctionCall + _csrClient cache — redundant third HTTP path
- 3 duplicate getCSRFToken() implementations → shared utils.ts

Fixed:
- invalidateFunctions() was ignoring function names and invalidating
  ALL mounted contexts. Now correctly passes names through.

33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:36:33 -04:00
1b5dca5ab3 SSR: file-path rendering, no component registry
The worker receives a file path in the JSON message, dynamically
imports it, renders it. No registerComponent API, no app entry file,
no export maps. Django's template backend resolves the template name
to an absolute path against DIRS, same as every other template engine.

  render(request, 'components/Hello.tsx', {'name': 'World'})

Verified working: curl http://localhost:8000/hello/ returns
  <div id="mizan-root"><div>Hello, World!</div></div>

Changes:
- worker.tsx: receives file path, dynamic import with cache
- bridge.py: sends file path instead of component name
- backend.py: resolves template name against DIRS to absolute path
- Fix bridge.py:147 bug (referenced deleted 'component' variable)
- Example app: Hello.tsx component, /hello/ view, template config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:33:01 -04:00
658cbebce1 Regenerate example code + fix broken paths from repo restructure
- Fix testapp/apps.py: import djarea_clients (file was never renamed)
- Fix fetch.mjs: command is export_djarea_schema not export_mizan_schema
- Fix harness package.json: dependency path to mizan-react after restructure
- Add package.json for generator (openapi-typescript dependency)
- Regenerate all example code with new protocol format:
  - generated.provider.tsx uses raw context responses + SSR hydration
  - generated.server.ts uses GET /ctx/global/ with response.ok check
  - generated.forms.ts, channels.ts, channels.hooks.tsx refreshed
- Remove stale generated.django.tsx and generated.django.server.ts
- Update imports: fixtures.tsx and main.tsx import from ./api (index)
- Use MizanContext instead of deprecated DjangoContext in examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:13:35 -04:00
711e92ac4d Fix protocol mismatch + add SSR hydration to codegen
Three bugs fixed:

1. MizanProvider.call() read data.data but server returns data.result.
   Now reads data.result and processes data.invalidate for server-driven
   invalidation (triggering refetch on mounted context providers).

2. GlobalContextLoader expected {error, data} wrapper but context GET
   returns raw bundled data. Fixed to iterate response directly.

3. Named context providers had same wrapper assumption. Fixed to
   setData(result) directly.

Two features added:

1. SSR hydration: GlobalContextLoader checks window.__MIZAN_SSR_DATA__
   on mount. If present, populates contexts from it and skips fetch.

2. SSR hydration: Named context providers check __MIZAN_SSR_DATA__ in
   useState initializer. If SSR data exists for their functions, they
   render immediately without fetching.

3. Server-driven invalidation in MizanProvider.call(): reads the
   invalidate array from mutation responses and triggers refetch on
   mounted providers. Generated mutation hooks' hardcoded invalidation
   is now redundant but idempotent — both paths coexist safely.

Also fixed FunctionSuccessResponse type to match new protocol:
  { result: T, invalidate?: [...] }

373 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:08:32 -04:00
c237a6379b Add CLAUDE.md — exhaustive technical reference for the codebase
Documents the three protocols (RPC, Invalidation-on-Mutation,
Frontend-Agnostic Rendering), the full @client decorator API surface
with all parameters and _meta structure, the HMAC cache key derivation
scheme, Redis/Memory backends, the MWT/JWT token systems with secret
separation, the SSR template backend + Bun worker bridge, the Edge
manifest format, and the current codegen gap.

Written from reading every source file, not from memory or prior docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 02:46:13 -04:00
4147679e6b Add SSR bridge: Django template backend + Bun subprocess renderer
Mizan's SSR is a Django template backend. Configure in TEMPLATES:

    TEMPLATES = [{
        'BACKEND': 'mizan.ssr.MizanTemplates',
        'OPTIONS': {'worker_path': 'frontend/ssr-worker.tsx'},
    }]

Then render(request, 'ProfilePage', {'user_id': 5}) renders the React
component via a persistent Bun subprocess. The component name is the
template name. The context dict becomes props.

Architecture:
- Bun worker: stdin/stdout JSON-RPC, renderToString, component registry
- Django bridge: subprocess lifecycle, crash recovery, concurrent renders
- Template backend: implements Django's BaseEngine interface

This is the AFI's SSR boundary:
- Backend adapter implements mizan.ssr() (data gathering)
- Frontend adapter implements renderToHTML() (component rendering)
- Bun subprocess is the runtime hosting the frontend adapter

11 tests: ping, render, error handling, crash recovery, concurrent
renders (5 threads), template backend integration. All require Bun.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 02:18:05 -04:00
e5f8fafc01 Remove CDN Cache-Control headers; fix cross-language sort bug
Mizan's protocol layers (origin Redis cache, Edge Worker) handle caching
autonomously. The origin emits Cache-Control: no-store on ALL responses —
browsers and non-Mizan intermediaries must not cache. The Edge Worker
controls CDN caching via cf object, independent of origin headers.

Also fixes:
- TS localeCompare → byte-order sort (localeCompare is locale-sensitive,
  would produce different HMAC keys for non-ASCII params vs Python)
- Python cache_purge: empty {} params no longer treated as falsy
  (was inconsistent with JS where {} is truthy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:38:24 -04:00
7f5542e305 Simplify cache: remove reverse indexes, use direct key reconstruction
The reverse index approach (Redis sets tracking HMAC keys per context)
was over-engineered. Scoped purge doesn't need an index — recompute
the HMAC key from the invalidation params and DELETE directly. One
Redis command, no TOCTOU race, no atomicity concern, no stale members.

Broad purge uses key-prefix scan (keys are now "ctx:{context}:{hmac}").
This is rare (Tier 3 fallback) and acceptable as a SCAN operation.

Eliminated from both Python and TypeScript:
- All SET/SADD/SMEMBERS/SREM index operations
- CacheBackend.get_index, remove_from_index, delete_index, delete_indexes_by_prefix
- build_index_keys function
- Pipeline transaction complexity
- TOCTOU race condition (was critical, now impossible)

Backend interface is now 5 methods: get, set, delete, delete_by_prefix, clear.
Redis tests updated — prefix isolation test added, connection leak fixed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:21:24 -04:00
dbbb269696 Add Redis backend tests against real Redis instance
13 tests hitting Redis on localhost:6399 (docker run redis:alpine):
- get/put/delete, index tracking, remove_from_index, delete_by_prefix
- TTL verification on cache entries AND index sets
- Pipeline atomicity (value + indexes written together)
- Scoped purge (AND semantics) against real Redis
- Broad purge with sub-index cleanup
- Tests skip gracefully if Redis is not available

No mocks, no fakes. Real Redis or skip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:10:35 -04:00
4744ff052e Add TypeScript cache adapter with cross-language conformance tests
Port of Python's origin-side cache to TypeScript:
- cache/keys.ts: deriveCacheKey with stableStringify for JSON-canonical HMAC
- cache/backend.ts: MemoryCache (same API as Python)
- cache/index.ts: cacheGet, cachePut, cachePurge with AND semantics

Integrated into dispatch.ts:
- handleContextFetch: cache lookup before execution, store after
- handleMutationCall: purge on invalidation

Cross-language pin test proves Python and TypeScript produce identical
HMAC-SHA256 output for the same inputs:
  Public:      605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6
  User-scoped: 30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2

34 TypeScript tests (9 new), 165 Python tests (1 new pin test).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:03:24 -04:00
54581d184f Fix MWT security issues from expert review
Critical:
- Separate MIZAN_MWT_SECRET from MIZAN_CACHE_SECRET — compromising one
  no longer compromises the other (token forgery vs cache poisoning)
- Move kid from JWT payload to JOSE header per RFC 7515 — standard
  libraries use header kid for key selection before payload decode

High:
- Full SHA-256 pkey (64 chars) instead of truncated 16 — no reason to
  reduce collision resistance
- Add nbf (not-before) claim for clock skew protection
- Log warnings in _try_mwt_auth on missing secret and decode failures
  instead of silent swallow
- Rename _csrf_protect_unless_jwt to _csrf_protect_unless_token (accuracy)
- decode_mwt logs at DEBUG level on failures for observability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:52:30 -04:00
d7ec13c43c Add MWT (Mizan Web Token) — protocol-owned identity layer
MWT is a standard JWT with Mizan-specific claims on X-Mizan-Token header:
- sub: user_id for HMAC cache key derivation
- pkey: deterministic hash of user's permission state (staff + superuser + perms)
- kid: key ID for future secret rotation
- aud: audience binding for cross-tenant protection

Executor checks X-Mizan-Token first, falls back to Authorization: Bearer
for legacy JWT compat. Invalid tokens return 401 (no session fallback).

New: mizan/mwt.py (create_mwt, decode_mwt, MWTUser, compute_permission_key)
New: mwt_obtain server function for session-to-MWT issuance
New: MIZAN_MWT_TTL setting (default 300s = 5 min permission staleness window)
11 new tests covering creation, decode, pkey determinism, auth integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:41:18 -04:00
a2388b3ab2 Add rev and cache parameters to @client decorator
rev=N: bumped by developer when function logic changes. Becomes part of
the HMAC cache key — old cache entries are unreachable without purge.
Effective rev for a context is max(rev) across all functions in it.

cache=int|False|True: TTL escape hatch for unobservable mutations.
cache=60 emits s-maxage=60. cache=False emits no-store. Default (True)
emits s-maxage=31536000 (forever, purge on mutation).
Effective cache for a context is min(TTL) across functions, with False
taking precedence.

Both parameters flow through: decorator → meta → manifest → cache key
and Cache-Control headers. Implemented in both Python and TypeScript
with 13 Python tests and 4 TypeScript tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:20:32 -04:00
7daec1c2e2 Fix remaining cache issues: index TTL, sub-index cleanup, top-level imports
- RedisCache.put: add pipe.expire() on index sets matching entry TTL,
  prevents orphaned index entries when cache values expire
- Broad purge: delete_indexes_by_prefix() cleans per-param sub-indexes
  (mizan:idx:ctx:k=v) that previously leaked as dead sets
- Move cache imports to top of executor.py (were inline in view functions)
- Update KNOWN_ISSUES.md — all 16 issues now resolved or documented

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:09:22 -04:00
b06a65e133 Fix critical cache issues: user_id scoping, AND purge, error handling, TTL
Fixes from 8-expert review:
- Pass user_id from request.user.pk to cache key derivation (data leak fix)
- Scoped purge uses AND (intersection) not OR (union) semantics
- All cache ops in executor wrapped in try/except with logging fallthrough
- Thread-safe cache initialization with threading.Lock
- RedisCache: 24h safety-net TTL, connection timeouts, MULTI/EXEC pipeline
- RedisCache.clear() uses pipelined UNLINK instead of per-batch DELETE
- build_index_keys now stringifies values matching derive_cache_key
- get_cache() logs warnings for partial config and connection failures
- Wire-protocol internals removed from __all__

Remaining open: purge atomicity (Lua script), cross-language str() canon,
broad purge sub-index cleanup, thundering herd protection, RedisCache tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:50:05 -04:00
b2f990b4e5 Architecture rework: fix protocol bugs, add origin-side cache, document spec
8-expert review identified 3 bugs in shipped code (Vary header hallucination,
fn/function wire key mismatch, max-age=0 defeating PSR) — all fixed with
tests updated across Python and TypeScript.

Added: manifest version field, affects validation, wire format convention,
origin-side cache module (HMAC key derivation, MemoryCache + RedisCache
backends, reverse index for scoped invalidation, executor integration).

16 known issues documented in cache/KNOWN_ISSUES.md from expert review —
critical items (user_id not passed, purge race condition, no Redis error
handling) to be fixed in follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:40:55 -04:00
97237ed1a4 Add mizan-ts: TypeScript backend adapter proving AFI is language-agnostic
The TypeScript adapter produces the same manifest, the same
X-Mizan-Invalidate headers, the same JSON invalidation protocol,
and the same CDN-ready response headers as mizan-django.

One Edge Worker. Two backend languages. Same protocol.

Features:
- @client decorator (function wrapper + class method decorator)
- ReactContext class (same API as Django adapter)
- Registry with context groups and param tracking
- Context bundled GET: /api/mizan/ctx/<name>/
- Mutation POST: /api/mizan/call/ with server-driven invalidation
- Three-tier auto-scoping (argument name matching → broad fallback)
- Function-level affects targeting
- private=True (rejected from RPC, in manifest for Edge)
- X-Mizan-Invalidate header with URL-encoded params
- Edge manifest generation (identical format to Django's)
- render_strategy + user_scoped derivation

22 edge compatibility tests pass (Bun, 21ms):
- Deterministic JSON, sorted keys
- Cache-Control: public on GETs, no-store on mutations/errors
- Vary: Authorization, Cookie
- Header round-trip with special characters
- Auto-scoped invalidation matches body and header
- Function-level invalidation
- Private function rejection
- Manifest structure with PSR/dynamic_cached strategies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
d228c7ab1b Add private=True, route=, methods= to @client decorator
private=True: server-internal functions (webhooks, cron) that emit
invalidation but are not client-callable. Rejected from POST /call/
with 403. No codegen. Appears in manifest for Edge.

  @client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
  def stripe_webhook(request) -> HttpResponse: ...

route=: Mizan-owned URL pattern for view-path functions. Registered
during autodiscovery. Populates page_routes in the manifest for
Edge/PSR to resolve during invalidation.

methods=: HTTP methods for the route. Defaults to ['GET'] for context
functions, ['POST'] for mutations.

Extended Edge manifest with:
- mutations section: affects, auto_scoped_params, private, route
- render_strategy: "psr" (no user params) or "dynamic_cached" (user-scoped)
- user_scoped: derived from param names matching common identity params
- page_routes: from route= on view-path functions + external view_urls

323 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
28e517e6ee Edge manifest: static JSON for CDN cache invalidation routing
generate_edge_manifest() compiles the decorator registry into a
static JSON artifact that Edge reads at deploy time:

{
  "contexts": {
    "user": {
      "functions": [
        {"name": "user_profile", "path": "rpc"},
        {"name": "profile_page", "path": "view"}
      ],
      "endpoints": ["/api/mizan/ctx/user/"],
      "params": ["user_id"],
      "views": ["/profile/:user_id/"]
    }
  }
}

When Edge receives X-Mizan-Invalidate: user;user_id=5, it:
1. Looks up "user" in the manifest
2. Resolves URL patterns: /profile/:user_id/ → /profile/5/
3. Purges /profile/5/ and /api/mizan/ctx/user/?user_id=5

Features:
- Distinguishes view-path vs RPC-path functions
- Accepts optional view_urls mapping from developer
- Custom base URL support
- Deterministic JSON output (sorted keys)
- Management command: python manage.py export_edge_manifest

314 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
b4c7e783bd Return-type branching: one decorator, two paths
@client now handles both RPC and view functions based on return type:

  @client(affects=UserContext)
  def update_name(request, user_id: int, name: str) -> dict:
      ...  # RPC path: JSON response with invalidate key

  @client(affects=UserContext)
  def update_profile(request, user_id: int) -> HttpResponse:
      ...  # View path: HttpResponse with X-Mizan-Invalidate header

Detection: isinstance(result, HttpResponseBase) after execution.

RPC path (data return):
  - Serialized via Pydantic model_dump()
  - Wrapped in {"result": ..., "invalidate": [...]}
  - Invalidation in JSON body + X-Mizan-Invalidate header

View path (HttpResponse return):
  - Response passed through directly (redirect, HTML, etc.)
  - X-Mizan-Invalidate header added automatically
  - Cache-Control: no-store added
  - No codegen (view_path=True in _meta)
  - Registered in invalidation graph (for Edge manifest)

Auto-scoping works on both paths: if mutation args overlap
with context params, invalidation is scoped automatically.

308 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
89196a02c6 Edge compatibility tests + URL-encode header param values
19 tests that prove Edge caching is possible before Edge exists:

- Deterministic JSON (byte-identical responses for same input)
- Sorted JSON keys for consistent cache keys
- Cache-Control: public on context GETs, no-store on mutations/errors
- Vary: Authorization, Cookie differentiates by auth state
- Auth-dependent responses: same URL, different user → different body
- X-Mizan-Invalidate header round-trip: format → parse → verify
- Header matches JSON body invalidation targets
- Special characters in param values: semicolons, spaces, quotes
  are URL-encoded to prevent delimiter collisions
- Large invalidation sets (20 contexts) serialize and parse correctly
- Concurrent mutations produce independent, correct headers
- Empty invalidation: no affects → no header, no body key
- Param order irrelevant for response determinism

Design decision: param values in X-Mizan-Invalidate are URL-encoded
(percent-encoded). This prevents semicolon collision when values
contain the delimiter character.

301 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
1a4da68f8d Add HTTP integration tests through full Django stack
9 tests that use Django's test Client instead of RequestFactory.
These go through URL routing, middleware (sessions, CSRF, auth),
and real request parsing — proving the protocol works end-to-end:

- Mutation with auto-scoped invalidation (JSON body + header)
- Context fetch with bundled response + CDN headers
- String-to-int query param coercion
- Broad invalidation fallback (no matching args)
- Function-level affects targeting
- 404 for unknown functions and contexts
- Method enforcement (GET-only on /ctx/, POST-only on /call/)

282 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
a91ce78c3a Replace affects_params with three-tier auto-scoping
Remove affects_params lambda. Scoping is now automatic:

Tier 1 - Argument name matching:
  If the mutation's args overlap with the context's params by name,
  the invalidation is auto-scoped. No developer annotation needed.

  @client(context=UserContext)
  def user_profile(request, user_id: int) -> UserShape: ...

  @client(affects=UserContext)
  def update_profile(request, user_id: int, name: str) -> dict: ...
  # user_id matches → invalidate: [{context: "user", params: {user_id: 5}}]

Tier 2 - Auth inference (Edge-side, not implemented in framework)
Tier 3 - Broad fallback when no param names match

Also adds function-level affects targeting:
  @client(affects='user_profile')  # only user_profile, not user_orders
  def update_name(request, user_id: int, name: str) -> dict: ...

Function names resolve to their parent context for param lookup.
v1 runtime refetches the whole context regardless, but the protocol
carries the function-level signal for Edge and future optimization.

273 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
37f3f3d3eb Add affects_params for scoped invalidation
affects_params is a callable that extracts which specific params were
affected by a mutation. The server uses it to produce scoped
invalidation in both transports:

  @client(affects=UserContext, affects_params=lambda req: {'user_id': req.user.pk})
  def update_avatar(request, url: str) -> dict: ...

JSON body: {"result": ..., "invalidate": [{"context": "user", "params": {"user_id": 42}}]}
Header:    X-Mizan-Invalidate: user;user_id=42

Edge reads the scoped params to purge only /profile/42/ instead of
all user profiles. The runtime refetches only the UserContext mounted
with user_id=42, not all UserContext instances.

Requires affects= to be set. Falls back to broad invalidation if
the callable fails.

272 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
8aa20111b4 Add X-Mizan-Invalidate header (second invalidation transport)
Mutation responses now carry invalidation via two transports:

1. JSON body: {"result": ..., "invalidate": ["user"]}
2. HTTP header: X-Mizan-Invalidate: user, notifications

Both are set on every mutation response. The JSON body is consumed
by the client runtime (mizanCall). The header is consumed by Edge
for CDN cache purging and by XHR responses for htmx-style apps.

Header format: comma-separated contexts, semicolon-separated params.
  X-Mizan-Invalidate: user;user_id=5, notifications

Also: _resolve_invalidation and _format_invalidate_header extracted
as reusable helpers for when return-type branching adds HttpResponse
support (view-path mutations will only use the header transport).

Updated ROADMAP.md with full v1 plan including both transports,
return-type branching, affects_params, and Edge manifest.

270 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
f4d7c64e3c Add CDN-ready headers, ROADMAP, fold runtime into mizan-react
CDN headers on context GETs (Edge-ready):
- Cache-Control: public, max-age=0, stale-while-revalidate=300
- Vary: Authorization, Cookie
- Deterministic JSON (sorted keys) for consistent cache keys
- Error responses: Cache-Control: no-store
- Mutation POSTs: Cache-Control: no-store

ROADMAP.md documents v1 deliverables and Mizan Cloud (Edge, Render,
Deploy) as closed-source products built on the open-source protocol.

mizan-runtime folded into mizan-react/src/runtime/ — framework-agnostic
split deferred until a second frontend adapter exists.

268 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
3f737132a2 Server-driven invalidation + raw context response format
Mutation responses now include invalidation directives from the server:

  POST /api/mizan/call/
  → {"result": {...}, "invalidate": ["user"]}

The client never hardcodes invalidation targets. The server resolves
affects= metadata and returns what to invalidate. mizan-runtime reads
the invalidate key and triggers refetches automatically.

Context fetch returns raw bundled data (not wrapped):

  GET /api/mizan/ctx/user/?user_id=5
  → {"user_profile": {...}, "user_orders": [...]}

Also fixed QueryDict handling (use .dict() not dict() to avoid
list-wrapped values).

267 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
787f90fd12 Flatten to three packages + extract mizan-runtime
packages/
  mizan-runtime/   Framework-agnostic state engine (~150 lines)
                   Context registry, batched invalidation, fetch primitives
  mizan-django/    Django server adapter (was packages/mizan-rpc/adapters/django/)
                   Codegen moved to mizan-django/generate/
  mizan-react/     React adapter (was packages/mizan-csr/adapters/react/)

Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.

mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.

264 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
b28ee72c67 Restructure repo into five-package AFI architecture
Mizan is an Application Framework Interface (AFI) with five
independent packages:

  packages/
    mizan-ast/       Language layer (source → KDL schema)
    mizan-schema/    IR layer (KDL schema definition)
    mizan-rpc/       Protocol layer (client gen + server adapters)
      adapters/django/   ← was django/
      generator/         ← was react/src/generator/
    mizan-csr/       State layer (client state engine)
      adapters/react/    ← was react/
    mizan-ssr/       Rendering layer (server-side rendering)

Each package is independent. The adapter directories contain the
framework-specific implementations. Stub packages (ast, schema, ssr)
establish the structure for future work.

264 Django tests + 33 React tests pass from new locations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
01d33173a4 Add ReactContext class for type-safe context and affects declarations
ReactContext('user') creates a reusable context marker that provides
proper linting, find-references, and autocomplete:

  UserContext = ReactContext('user')

  @client(context=UserContext)
  def user_profile(request, user_id: int) -> ProfileShape: ...

  @client(affects=UserContext)
  def edit_profile(request, name: str) -> dict: ...

  @client(affects=[UserContext, OrderContext])
  def change_plan(request) -> dict: ...

- ReactContext class with name validation
- GlobalContext built-in instance for context='global'
- affects= accepts ReactContext, lists, strings, or function refs
- Backwards compat: raw strings still work for context= and affects=
- Exported from mizan and mizan.client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
af7e22ffc1 Rewrite codegen for named contexts, mutation hooks, and Mizan naming
Generator (react/src/generator/lib/mizan.mjs):
- Rename djarea.mjs → mizan.mjs
- MizanContext replaces DjangoContext (legacy alias kept)
- Global contexts fetched via bundled GET /ctx/global/ through
  inner GlobalContextLoader component
- Named context providers generated per context group with param
  elevation (required/optional props from x-mizan-contexts)
- Mutation hooks auto-invalidate affected contexts on success
- SSR hydration uses single GET /ctx/global/ instead of N POSTs
- Output files: generated.provider.tsx, generated.server.ts

Runtime (react/src/context.tsx):
- Add setContextData() for bundle splitting without refetch
- Add request() for auth-transparent HTTP from generated code

Index generator updated for new export names and named contexts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
3523f2e3fe Add named contexts, bundled fetch endpoint, and affects invalidation
Phase 1 (Named Contexts):
- @client(context=) accepts any string, not just 'global'/'local'
- context='local' emits deprecation warning
- Registry groups functions by context name (get_context_groups)
- GET /api/mizan/ctx/<name>/ bundles all context functions in one response
- Schema export includes x-mizan-contexts with param elevation metadata

Phase 2 (Affects):
- @client(affects=) declares mutation invalidation targets
- Accepts context name strings, function refs, or lists
- Mutually exclusive with context=
- Exported in x-mizan-functions schema for codegen

React runtime:
- MizanContextValue gains invalidateContext, invalidateFunctions,
  registerContextProvider, and baseUrl
- Named context providers register for invalidation on mount

259 Django tests pass, 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
f3c225ef49 Move Playwright, Docker, and package.json into examples/django-react-site
Root directory now contains only the two core packages (django/, react/),
examples/, and top-level docs. All e2e/integration test infrastructure
lives in examples/django-react-site/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
eee352d908 Move desktop and e2e into examples/ directory
- desktop/ → examples/django-react-desktop-app/
- e2e/ → examples/django-react-site/
- example/ → examples/django-react-site/backend/
- Update Dockerfile.test, Makefile, playwright config, and
  django.config.mjs path references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
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
bf837e598b Add Mizan architecture plan
Named contexts, param elevation, affects-based invalidation,
ReactContext classes for read/write coupling. This is the evolution
roadmap from Djarea to Mizan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:13 -04:00
501 changed files with 48141 additions and 5902 deletions

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
working-directory: django working-directory: backends/mizan-django
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
working-directory: react working-directory: frontends/mizan-react
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

10
.gitignore vendored
View File

@@ -11,6 +11,10 @@ node_modules/
dist/ dist/
package-lock.json package-lock.json
# Rust — every crate's build dir, anywhere in the tree
target/
**/target/
# Playwright # Playwright
/test-results/ /test-results/
/playwright-report/ /playwright-report/
@@ -21,9 +25,9 @@ package-lock.json
.vscode/ .vscode/
# Build artifacts # Build artifacts
desktop/frontend/dist/ examples/django-react-desktop-app/frontend/dist/
e2e/harness/src/api/generated.* examples/django-react-site/harness/src/api/generated.*
e2e/harness/test-results/ examples/django-react-site/harness/test-results/
# Env # Env
.env .env

107
INVARIANTS.md Normal file
View File

@@ -0,0 +1,107 @@
# Application Framework Interface Invariants
All invariants are absolute. Agents are not permitted to modify this file unless **DIRECTLY PROMPTED BY RYTH**.
If an invariant is not satisfiable by the backend's native functionality (for example, FastAPI is missing a native ORM for Shapes),
then a canonical technology must be proposed. The technology *MUST* be approved by Ryth before implementation.
## Backend Adapters
Django (python)
FastAPI (python)
Typescript (generic)
Rust/Axum (generic)
Tauri (Rust)
## Frontend Adapters
React (Typescript)
Vue (Typescript)
Svelte (Typescript)
Tauri (Rust)
### Client Function RPC
---
No REST endpoints.
Client functions are decorated functions (decorator or registration call at definition-site) that both receive and return HTTP & JSON compliant arguments.
The decoration mechanism must implement the full variadic or kwarg set (websocket, auth, context wiring).
### WebSocket Support
---
A client function declared `websocket=` is dispatched over a persistent connection rather than request/response. Server-initiated messages reach the subscribed contexts; invalidation travels the socket with the same semantics it has over HTTP.
The per-adapter transport differs — Django Channels, a native WebSocket route, a Tauri IPC subscription channel — but the declaration and the wire semantics do not. Mixing socket and non-socket transport within one context is a registration-time error.
### Named Contexts
---
Any string passed to `context=` is a named context. Functions sharing a context name are grouped at registration into one provider, one fetch, and one set of generated hooks — a single read request, never N round-trips. `context='global'` is the one reserved name: fetched once at the root and SSR-hydrated.
Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override. A read context is GET-dispatched and cacheable, and it is the unit a mutation invalidates.
### Mutation Invalidation
---
A mutation declares what it `affects=` — a context name, a function reference, or a list — and that relationship is generated into the client. On success the affected contexts refetch; on failure nothing invalidates. The developer never writes a cache key, never calls an invalidate function, never maintains a query-key map.
Invalidation auto-scopes by matching parameter name: a mutation carrying `user_id=123` invalidates the `user_id=123` entry, not the whole context.
This is the invariant that separates the AFI from typed RPC. An adapter that dispatches calls and projects shapes but leaves the client hand-writing invalidation has not satisfied it. The client holds a server-reconciled view, never a parallel source of truth.
### API Shapes
---
A backend adapter supports the "API Shape" feature to the fullest extent:
- ORM Integration
- Auto-diffing (Receive a list of objects, check primary keys for add/modify/delete semantics, use Django as reference)
- Backend-for-Frontend Authoring DX (Shape schema must be easily authorable near used function)
### Auth
---
A function declaring `auth=` is enforced at dispatch on every adapter — the guard rejects before the function body runs, identically across transports. Authorization is a property of the declared function, carried in the IR, not middleware an adapter bolts on or omits.
### File Uploads
---
The `Upload` type is a first-class argument carried end to end — IR, codegen, and dispatch binding. Arguments are otherwise HTTP- and JSON-compliant; `Upload` is the one binary exception, bound from multipart over HTTP and from the envelope over IPC. The declaration is uniform; the transport binding is per-adapter.
### Canonical IR & Codegen
---
Every backend adapter emits the canonical KDL IR describing its functions, contexts, types, and invalidation graph. Every frontend client is generated from that IR. No REST envelope, no OpenAPI document, no per-backend converter sits between a backend and a frontend — the IR is the only contract.
This is the invariant that collapses the backends × frontends quadratic to one adapter per stack. A backend that does not emit the IR, or a frontend not generated from it, is outside the AFI: the boundary is the IR, and nothing crosses it untyped.
### Client Kernel
---
Every frontend adapter is a thin idiomatic wrapper over one shared kernel. The kernel owns the reconciled cache — context state, status, error, server-driven merge and invalidate, session init — and reaches the backend through a pluggable transport (HTTP, Tauri IPC, webview channel). Framework adapters subscribe and render in their own idiom (React hooks, Vue composables, Svelte runes); codegen targets the adapter surface, never the raw kernel.
No adapter keeps its own copy of the truth. The reconciled view lives once, in the kernel.
### SSR
---
Server rendering is the AFI's second product, orthogonal to RPC and composable with it — either ships standalone. A function's registered render strategy renders on the server through the bridge and hydrates on the client; the contexts a page reads are SSR-hydrated at the root, so first paint carries data rather than a loading state.
## Compositions
Stdlib over the invariants above, not invariants in themselves — named so the boundary is explicit and an adapter is never marked short for lacking them as primitives:
- **Forms** — three role-tagged client functions (schema / validate / submit) plus field validation. RPC and validation composed; not its own primitive.
- **Context classes (`send` / `receive`)** — the read/write class form with Shape diffing. Named Contexts + API Shapes + Mutation Invalidation composed into one declaration; the heavy DX surface over the primitives, not a new primitive.

23
ISSUES.md Normal file
View File

@@ -0,0 +1,23 @@
# Mizan — Known Issues
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.
## Open
- [ ] **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`.
## Resolved this pass
- [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.

95
LICENSE Normal file
View File

@@ -0,0 +1,95 @@
Copyright (c) 2026 Ryth Azhur
Elastic License 2.0
URL: https://www.elastic.co/licensing/elastic-license
## Acceptance
By using the software, you agree to all of the terms and conditions below.
## Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide,
non-sublicensable, non-transferable license to use, copy, distribute, make
available, and prepare derivative works of the software, in each case subject to
the limitations and conditions below.
## Limitations
You may not provide the software to third parties as a hosted or managed
service, where the service provides users with access to any substantial set of
the features or functionality of the software.
You may not move, change, disable, or circumvent the license key functionality
in the software, and you may not remove or obscure any functionality in the
software that is protected by the license key.
You may not alter, remove, or obscure any licensing, copyright, or other notices
of the licensor in the software. Any use of the licensors trademarks is subject
to applicable law.
## Patents
The licensor grants you a license, under any patent claims the licensor can
license, or becomes able to license, to make, have made, use, sell, offer for
sale, import and have imported the software, in each case subject to the
limitations and conditions in this license. This license does not cover any
patent claims that you cause to be infringed by modifications or additions to
the software. If you or your company make any written claim that the software
infringes or contributes to infringement of any patent, your patent license for
the software granted under these terms ends immediately. If your company makes
such a claim, your patent license ends immediately for work on behalf of your
company.
## Notices
You must ensure that anyone who gets a copy of any part of the software from you
also gets a copy of these terms.
If you modify the software, you must include in any modified copies of the
software prominent notices stating that you have modified the software.
## No Other Rights
These terms do not imply any licenses other than those expressly granted in
these terms.
## Termination
If you use the software in violation of these terms, such use is not licensed,
and your licenses will automatically terminate. If the licensor provides you
with a notice of your violation, and you cease all violation of this license no
later than 30 days after you receive that notice, your licenses will be
reinstated retroactively. However, if you violate these terms after such
reinstatement, any additional violation of these terms will cause your licenses
to terminate automatically and permanently.
## No Liability
*As far as the law allows, the software comes as is, without any warranty or
condition, and the licensor will not be liable to you for any damages arising
out of these terms or the use or nature of the software, under any kind of
legal claim.*
## Definitions
The **licensor** is the entity offering these terms, and the **software** is the
software the licensor makes available under these terms, including any portion
of it.
**you** refers to the individual or entity agreeing to these terms.
**your company** is any legal entity, sole proprietorship, or other kind of
organization that you work for, plus all organizations that have control over,
are under the control of, or are under common control with that
organization. **control** means ownership of substantially all the assets of an
entity, or the power to direct its management and policies by vote, contract, or
otherwise. Control can be direct or indirect.
**your licenses** are all the licenses granted to you for the software under
these terms.
**use** means anything you do with the software requiring one of your licenses.
**trademark** means trademarks, service marks, and similar rights.

475
MIZAN.md Normal file
View File

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

View File

@@ -1,37 +1,56 @@
.PHONY: install test test-django test-react test-integration docker-up docker-down clean .PHONY: install test test-core test-django test-fastapi test-react test-afi test-integration docker-up docker-down clean
CORE = cores/mizan-python
DJANGO = backends/mizan-django
FASTAPI = backends/mizan-fastapi
REACT = frontends/mizan-react
AFI = tests/afi
# ─── Setup ─────────────────────────────────────────────────────────────────── # ─── Setup ───────────────────────────────────────────────────────────────────
install: install:
cd django && pip install -e ".[dev,channels]" cd $(CORE) && uv pip install -e .
cd react && npm install cd $(DJANGO) && uv pip install -e ".[dev,channels]"
cd $(FASTAPI) && uv pip install -e ".[dev]"
cd $(REACT) && npm install
# ─── Unit Tests ────────────────────────────────────────────────────────────── # ─── Unit Tests ──────────────────────────────────────────────────────────────
test: test-django test-react test: test-core test-django test-fastapi test-react test-afi
test-core:
cd $(CORE) && uv run --extra dev pytest
test-django: test-django:
cd django && pytest cd $(DJANGO) && uv run pytest
test-fastapi:
cd $(FASTAPI) && uv run pytest
test-react: test-react:
cd react && npm test cd $(REACT) && npm test
# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent
# schemas for the same @client fixture. Substrate-level gate, not e2e.
test-afi:
cd $(AFI) && uv run pytest
# ─── Integration Tests ────────────────────────────────────────────────────── # ─── Integration Tests ──────────────────────────────────────────────────────
test-integration: docker-up test-integration: docker-up
@echo "Waiting for backend..." @echo "Waiting for backend..."
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/djarea/session/ > /dev/null 2>&1; do sleep 1; done' @timeout 30 sh -c 'until curl -sf http://localhost:8000/api/mizan/session/ > /dev/null 2>&1; do sleep 1; done'
cd react && npm run test:integration cd $(REACT) && npm run test:integration
@$(MAKE) docker-down @$(MAKE) docker-down
# ─── Docker ────────────────────────────────────────────────────────────────── # ─── Docker ──────────────────────────────────────────────────────────────────
docker-up: docker-up:
docker compose -f docker-compose.test.yml up -d --build docker compose -f examples/django-react-site/docker-compose.test.yml up -d --build
@echo "Backend starting at http://localhost:8000" @echo "Backend starting at http://localhost:8000"
docker-down: docker-down:
docker compose -f docker-compose.test.yml down docker compose -f examples/django-react-site/docker-compose.test.yml down
# ─── All ───────────────────────────────────────────────────────────────────── # ─── All ─────────────────────────────────────────────────────────────────────
@@ -40,7 +59,7 @@ test-all: test test-integration
# ─── Cleanup ───────────────────────────────────────────────────────────────── # ─── Cleanup ─────────────────────────────────────────────────────────────────
clean: clean:
docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true docker compose -f examples/django-react-site/docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
rm -rf django/src/djarea.egg-info django/dist django/build rm -rf $(DJANGO)/src/mizan.egg-info $(DJANGO)/dist $(DJANGO)/build
rm -rf react/dist react/node_modules rm -rf $(REACT)/dist $(REACT)/node_modules
rm -f example/db.sqlite3 rm -f examples/django-react-site/backend/db.sqlite3

481
README.md
View File

@@ -1,369 +1,122 @@
# DJAREA # Mizan
A modern Django + React Framework for perfectionists with deadlines. Mizan is an Application Framework Interface (AFI). A single `@client` decorator on a
server function generates a typed frontend client; cache invalidation and caching are
Write a Pydantic function, add the @client decorator, use configurable **Shape** types for your models. handled by the protocol.
Djarea generates the entire React client: all your type interfaces, function call hooks, autoatic JWT, and a simple `<DjangoContext/>` to make it all work.
No API routing, no serializers, no REST/CRUD bullshit.
```python ```python
@client from mizan import client, ReactContext
def current_user(request) -> UserShape:
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0] UserContext = ReactContext('user')
# Context function — bundled into GET /api/mizan/ctx/user/
@client(context=UserContext)
def user_profile(request, user_id: int) -> UserShape:
return UserShape.query(lambda qs: qs.filter(pk=user_id))[0]
# Mutation — invalidation scoped automatically by matching param name
@client(affects=UserContext)
def update_profile(request, user_id: int, name: str) -> dict:
...
``` ```
Adapters exist for Django, FastAPI, Rust/Axum, Tauri, and TypeScript. Django is the
```tsx reference implementation; per-adapter support is inventoried below.
const user: UserShape = useCurrentUser() // typed, cached, SSR-hydrated
``` > **Status:** Mizan is not production-tested. It passes its own test suites but has not
> been run in a production deployment. Treat it as pre-release.
The **Function** is the API contract. The **Shape** is the query. The hook is the artifact. That's it.
## Documentation
Starts with session auth and upgrades to JWT on login. **It just works**.
- [`docs/`](docs/) — architecture references: AFI, SSR, cache keying, MWT, PSR vs. Edge
## What Djarea does - [`ROADMAP.md`](ROADMAP.md) · [`ISSUES.md`](ISSUES.md) — planned work and known gaps
A `@client` function in Django becomes a callable hook in React. The function's type signature orchestrates the entire pipeline for you — input validation, output serialization, TypeScript interfaces, and SQL projection. ## Backend adapters
```python Every adapter implements the same AFI wire protocol. The matrix below inventories
class ArticleShape(Shape[Article]): support per adapter, grouped to separate protocol guarantees from Django-specific
id: int | None = None features (forms, ORM projection, auth providers, SSR). A cell counts as supported only
title: str when that adapter wires the capability into its own dispatch surface, not merely that a
author: FlatAuthorShape shared core primitive exists.
tags: list[TagShape] = []
``` Legend: ✅ supported · ◑ partial · ❌ not implemented · — not applicable to this transport
One Djarea **Shape** does three things simultaneously: ### Protocol core
- Defines the Pydantic model for validation and serialization
- Generates a django-readers spec for a lean, field-scoped SQL query The surface every Mizan adapter implements.
- Produces the TypeScript interface on the React side
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
Shapes are your codebase's **single source of truth** for backend/frontend data transfer. |---|:---:|:---:|:---:|:---:|:---:|
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ ¹ | ✅ |
## Quick start | Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
### 1. Django setup | Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
```python | Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
# settings.py
INSTALLED_APPS = [ ### Edge, cache & enforcement
"djarea",
"myapp", Protocol transports and guarantees co-equal with the body channel in the spec.
]
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
# urls.py |---|:---:|:---:|:---:|:---:|:---:|
from django.urls import include, path | Invalidation — `X-Mizan-Invalidate` header | ✅ | ❌ | ❌ | — ¹ | ✅ |
urlpatterns = [ | Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ❌ |
path("api/djarea/", include("djarea.urls")), | Origin-side HMAC cache | ✅ | ❌ | ❌ | ❌ | ✅ |
] | Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
# asgi.py (for WebSocket support) | Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ |
from djarea import wrap_asgi
from django.core.asgi import get_asgi_application > **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce
application = wrap_asgi(get_asgi_application()) > it — do not rely on `auth=` for access control on those adapters.
```
### Stack extensions (Django)
### 2. Define your client functions
Django ecosystem features Mizan wraps. Other adapters provide these only where the
```python target stack calls for them.
# myapp/clients.py
from djarea.client import client | Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
from djarea.shapes import Shape |---|:---:|:---:|:---:|:---:|:---:|
from pydantic import BaseModel | WebSocket channels (declared transport) | ✅ | ❌ | ◑ ² | ❌ | ❌ |
| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ |
class EchoOutput(BaseModel): | Formsets | ✅ | ❌ | ❌ | ❌ | ❌ |
message: str | API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — |
| JWT auth (access / refresh, session validation) | ✅ | ❌ | ❌ | ❌ | ❌ |
@client | MWT (edge identity token) | ✅ | ❌ | ❌ | — | ❌ |
def echo(request, text: str) -> EchoOutput: | SSR bridge | ✅ | ❌ | ❌ | — | ❌ |
return EchoOutput(message=text) | Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ |
```
**Notes**
Functions in `clients.py` are discovered automatically — same convention as `models.py`.
1. Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP.
### 3. Generate TypeScript Invalidation rides in the JSON response body; there is no header channel.
2. Rust/Axum declares `Transport::Websocket` in the IR/macro but routes no Axum
To get your generated React client, set this up in your frontend root: WebSocket handler yet.
3. Rust/Axum carries `is_form`/`form_role` trait stubs but no validate/submit endpoint.
```javascript 4. "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every
// django.config.mjs adapter carries typed input/output through the KDL IR; the projection primitive
export default { itself is Django-only.
source: { 5. Tauri's `FunctionSpec` carries `auth`/`private` fields; the dispatch path does not
django: { enforce them. Rust/Axum has no enforcement either.
managePath: '../backend/manage.py', 6. Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme
command: ['uv', 'run', 'python'], registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin)
}, rather than fetching over HTTP.
}, 7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
output: 'src/api/generated.ts', parity; CSRF is Django-only.
} 8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
``` codegen source — it demonstrates the cache + invalidation protocol is
language-agnostic.
Run this command everytime your client needs updating. You can also throw this it on a file watcher pointed at your backend code:
## Conformance
```bash
npx djarea-generate Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/). It
``` currently asserts **IR-shape parity** — the same fixture through Django, FastAPI, and
the Rust adapter emits byte-identical KDL (`test_codegen_parity.py`). Per-capability
### 4. Use in React runtime assertions (header transport, `auth=` enforcement, cache behavior) are planned.
```tsx ## License
import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'
Mizan is licensed under the [Elastic License 2.0](LICENSE) (SPDX: `Elastic-2.0`). You
// layout.tsx — one provider, handles everything may use, copy, modify, and distribute it freely, including in commercial products you
export default function Layout({ children }) { build on top of it. You may **not** provide Mizan to third parties as a hosted or
return <DjangoContext>{children}</DjangoContext> managed service that exposes a substantial set of its features.
}
// page.tsx
function MyComponent() {
const user = useCurrentUser()
const echo = useEcho()
const handleClick = async () => {
try {
const result = await echo({ text: 'hello' })
console.log(result.message) // typed
} catch (e) {
if (e instanceof DjangoError) {
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
e.getFieldErrors('email') // field-level errors
}
}
}
}
```
## Shapes
Shapes are Djarea's data protocol. A Shape defines exactly which fields to select from the database, validated through Pydantic and projected through django-readers. Different views get different Shapes — same model, different queries.
```python
# Full detail page — joins books with chapters
class AuthorDetailShape(Shape[Author]):
id: int | None = None
name: str
bio: str
books: list[BookShape] = []
# Dropdown menu — two columns, no joins
class FlatAuthorShape(Shape[Author]):
id: int | None = None
name: str
```
```python
# Detail page: SELECT id, name, bio + prefetch books
authors = AuthorDetailShape.query()
# Dropdown: SELECT id, name. That's it.
authors = FlatAuthorShape.query()
```
Shapes also support diffing. When the frontend sends state back, the diff system compares incoming data against the current database state and tells you exactly what changed:
```python
@client
def update_articles(request, articles: list[ArticleShape]) -> dict:
for article, diff in ArticleShape.diff_many(articles):
if diff.is_new:
create_article(article)
elif diff.changed:
update_fields(article, diff.changed)
for tag in diff.tags.created:
add_tag(article, tag)
for tag_id in diff.tags.deleted:
remove_tag(article, tag_id)
return {"ok": True}
```
One query fetches all current state. The diff is per-field and per-nested-relation. Your service code only touches what actually changed.
## The `@client` decorator
The decorator controls transport, caching, auth, and SSR behavior:
| Decorator | React hook | What it does |
|-----------|-----------|--------------|
| `@client` | `useEcho()` | HTTP call, returns typed result |
| `@client(context='global')` | `useCurrentUser()` | Fetched once, cached in context, SSR-hydrated |
| `@client(context='local')` | `useArticle({ id })` | Cached per unique params |
| `@client(websocket=True)` | `useSearch()` | Runs over WebSocket instead of HTTP |
| `@client(auth=True)` | — | Requires authentication |
| `@client(auth='staff')` | — | Requires staff status |
| `@client(auth=my_check)` | — | Custom auth callable |
## Forms
Django forms become typed React hooks with client-side Zod validation:
```python
class ContactForm(DjareaFormMixin, forms.Form):
djarea = DjareaFormMeta(
name="contact",
title="Contact Us",
submit_label="Send",
live_validation=True,
)
name = forms.CharField(max_length=100)
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
def on_submit_success(self, request):
send_email(self.cleaned_data)
return {"sent": True}
```
```tsx
const form = useContactForm()
form.schema // field metadata, title, submit label
form.data // { name: '', email: '', message: '' }
form.set('email', v) // typed setter
form.errors // field-level errors (Zod + server)
form.submit() // → { success: true, data: { sent: true } }
```
Zod schemas are generated from the Django form definition. Validation runs client-side first, server-side second. No duplicated validation logic.
## Channels
WebSocket channels with typed messages:
```python
class ChatChannel(ReactChannel):
class Params(BaseModel):
room: str
class ReactMessage(BaseModel):
text: str
class DjangoMessage(BaseModel):
text: str
user: str
def authorize(self, params):
return self.user.is_authenticated
def group(self, params):
return f"chat_{params.room}"
def receive(self, params, msg):
return self.DjangoMessage(text=msg.text, user=self.user.email)
```
```tsx
const chat = useChatChannel({ room: 'general' })
chat.status // 'connecting' | 'connected' | 'disconnected'
chat.messages // ChatDjangoMessage[]
chat.send({ text: 'hello' })
```
## Architecture
```
React app
└─ <DjangoContext> ← generated provider (session, CSRF, WebSocket)
├─ useCurrentUser() ← context hook (SSR-hydrated)
├─ useEcho() ← function hook
├─ useContactForm() ← form hook (Zod + server validation)
└─ useChatChannel() ← channel hook (WebSocket)
├─ HTTP: POST /api/djarea/call/ { fn: "echo", args: { text: "hi" } }
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
Django executor
├─ Pydantic input validation
├─ Auth check
├─ Function execution
└─ Pydantic output serialization
```
All transport goes through a single endpoint. The generated `DjangoContext` is the only provider. It handles session init, CSRF, context auto-fetching, and WebSocket connection.
## Code generation
`npx djarea-generate` reads Django schemas at build time (no running server) and produces:
| File | Contents |
|------|----------|
| `generated.djarea.ts` | Pydantic model types |
| `generated.django.tsx` | `DjangoContext` provider + typed hooks |
| `generated.django.server.ts` | SSR hydration helper |
| `generated.forms.ts` | Form hooks with Zod schemas |
| `generated.channels.ts` | Channel message types |
| `generated.channels.hooks.tsx` | Channel hooks |
| `index.ts` | Re-exports |
## Error handling
All errors from server functions throw as `DjangoError`:
```tsx
if (e instanceof DjangoError) {
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | ...
e.message // human-readable
e.details // field-level validation errors
e.isAuthError()
e.isValidationError()
e.getFieldErrors('email')
}
```
## Why RPC instead of REST
REST exposes your database tables as CRUD endpoints and pushes business logic to the frontend. "Submit an application" becomes PATCH one resource, POST another, PUT a third — choreographed by client code.
Djarea keeps business logic on the server. You write functions that do things. The frontend calls them. The server knows what "submit" means. The client doesn't need to.
If you delete the frontend of a REST app, your backend is a database. If you delete the frontend of a Djarea app, your backend still has your entire application logic.
## Packages
| Package | Install |
|---------|---------|
| `djarea` (Python) | `pip install djarea` |
| `@rythazhur/djarea` (TypeScript) | `npm install @rythazhur/djarea` |
For WebSocket support: `pip install "djarea[channels]"`
## Testing
```bash
# Django
cd django && uv run pytest
# React
cd react && npm test
# E2E (Playwright, real browser + real backend)
docker compose -f docker-compose.test.yml up -d
cd e2e/harness && npx djarea-generate && npx playwright test
# Everything
make test-all
```
## Project structure
```
djarea/
django/ Python package
react/ TypeScript package
example/ Integration test backend
e2e/ Playwright E2E tests
Makefile Test orchestration
```
## Disclosure
Djarea was developed with the assistance of IDE AI Assistance and later with Claude Code.
The architecture, design decisions, developer experience standards and technical direction are mine. I've been programming for 16 years and have a lot of opinions!
DX ideas are inspired by the amazing work of these projects and the hardworking folks behind them:
- Django Ninja
- Django Readers
- Django RAPID Architecture
- React
- Next.js

93
ROADMAP.md Normal file
View File

@@ -0,0 +1,93 @@
# Mizan Roadmap
## v1 — Django + Multi-Framework (React, Vue, Svelte)
### Done
- [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/<name>/` 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
- [ ] **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`).
---
## Core Consolidation — Rust Binary
Move all core functionality unrelated to language introspection into the Rust binary. Other languages invoke it through FFI (PyO3 and equivalents) rather than carrying their own copy — centralizing behavior for the whole Mizan toolchain.
Language-specific core code then exists only for actual framework mechanics — registering client functions, binding Shapes to an ORM — never for behavior the binary already owns.
**SSR in the binary.** Because SSR works directly from the IR's typed schemas, the binary can drive it rather than forcing each backend adapter to author SSR by hand. That also lets the binary own SSR validation, keeping it consistent across adapters instead of each backend deriving it manually and drifting apart.
---
## Mizan Cloud (closed-source)
### Mizan Edge
Cloudflare Workers for automatic edge caching.
- Reads the edge manifest to configure cache rules
- Context GETs cached at edge, keyed by context name + params
- Reads `X-Mizan-Invalidate` header from mutation responses to purge caches
- Reads JSON `invalidate` key from RPC responses for the same purpose
- Resolves URL patterns from manifest to purge view pages
- Zero configuration — the manifest IS the cache policy
### Mizan Render
SSR at the edge via Cloudflare Workers.
- The Bun SSR bridge, running on Cloudflare instead of colocated with Django
- Context data fetched from Django (or edge cache), rendered at the edge
- HTML response streamed to the user from the nearest PoP
### Mizan Deploy
One-command deployment for Django + React apps.
- Container orchestration (AWS/Azure)
- Edge + Render auto-configured
- `mizan deploy` from the CLI
- The Vercel experience for Django
---
## Reference
Wire protocol shapes (context fetch, mutation call, invalidation transports) are documented in `CLAUDE.md`. Architectural details for specific subsystems live in `docs/`:
- `docs/AFI_ARCHITECTURE.md` — package architecture, kernel model, adapter strategy
- `docs/CACHE_KEYING.md` — HMAC cache key derivation
- `docs/MWT_SPEC.md` — Mizan Web Token format
- `docs/SSR_ARCHITECTURE.md` — Django template backend, Bun bridge
- `docs/PSR_VS_EDGE.md` — protocol-level rendering vs. paid Edge layer
- `docs/PRODUCT_ARCHITECTURE.md` — product surface and pricing tiers

View File

@@ -0,0 +1,206 @@
# mizan-django
Django backend adapter for the Mizan protocol. One decorator on a server
function. Typed React client generated. Invalidation automatic.
## Install
```bash
uv add "mizan[channels]"
# or with allauth integration:
uv add "mizan[channels,allauth]"
```
## Setup
```python
# settings.py
INSTALLED_APPS = ["mizan", "myapp", ...]
MIZAN_CACHE_SECRET = "..." # 32-byte HMAC signing key
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0"
MIZAN_MWT_SECRET = "..." # MWT signing key (separate from cache + JWT)
```
```python
# urls.py
from django.urls import include, path
urlpatterns = [
path("api/mizan/", include("mizan.urls")),
]
```
```python
# asgi.py — for WebSocket / Channels support
from django.core.asgi import get_asgi_application
from mizan import wrap_asgi
application = wrap_asgi(get_asgi_application())
```
## Define server functions
```python
# myapp/clients.py
from mizan.client import client
from mizan.setup import register
from pydantic import BaseModel
class EchoOutput(BaseModel):
message: str
@client
def echo(request, text: str) -> EchoOutput:
return EchoOutput(message=text)
register(echo, "echo")
```
Auto-discover `clients.py` modules from each Django app:
```python
# myapp/apps.py
from django.apps import AppConfig
class MyAppConfig(AppConfig):
name = "myapp"
def ready(self) -> None:
from mizan.setup import mizan_clients
mizan_clients("myapp") # imports myapp/clients.py — triggers @client side effects
```
## `@client` parameters
```python
@client # plain RPC function
@client(context="global") # singleton context — fetched once, SSR-hydrated
@client(context="user") # named context — fetched per provider mount
@client(affects="user") # mutation — invalidates the user context
@client(affects=user_profile) # mutation — invalidates a specific function
@client(websocket=True) # WebSocket transport (requires channels)
@client(auth=True) # requires authentication
@client(auth="staff") # requires is_staff
@client(auth="superuser") # requires is_superuser
@client(auth=lambda req: ...) # custom predicate
@client(route="/profile/<id>/") # view-path function (returns HttpResponse)
@client(rev=2) # cache revision (busts on bump)
```
## Forms
Django Forms become server functions + typed React hooks with Zod validation:
```python
from django import forms
from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(name="contact", title="Contact Us", submit_label="Send")
name = forms.CharField()
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
def on_submit_success(self, request):
send_email(self.cleaned_data)
return {"sent": True}
```
Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Frontend
gets `useContactForm()`.
## Channels
WebSocket-native RPC via a flag flip:
```python
from pydantic import BaseModel
from mizan.channels import ReactChannel
class ChatChannel(ReactChannel):
class Params(BaseModel):
room: str
class DjangoMessage(BaseModel):
text: str
user: str
def authorize(self, params):
return self.user.is_authenticated
def group(self, params):
return f"chat_{params.room}"
```
Frontend gets `useChatChannel({ room })`.
## Generate the frontend
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:
```toml
# frontend/mizan.toml
output = "src/api"
targets = ["react"]
[source.django]
manage_path = "../backend/manage.py"
command = ["uv", "run", "python"] # optional — defaults to ["python"]
[source.django.env]
PYTHONPATH = "../backend"
DJANGO_SETTINGS_MODULE = "myproject.settings"
```
```bash
mizan-generate --config mizan.toml
```
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 (`<MizanContext>`
provider, per-context providers, `use{Hook}()` hooks) into `src/api/`.
```tsx
// app.tsx
import { MizanContext } from "./api"
export default function App({ children }) {
return <MizanContext baseUrl="/api/mizan">{children}</MizanContext>
}
```
```tsx
// any component
import { useEcho, useCurrentUser } from "./api"
const echo = useEcho()
echo.mutate({ text: "hi" }).then(r => console.log(r.message))
const user = useCurrentUser() // global context — auto-fetched, auto-refreshed on mutation
```
## Running tests
```bash
uv sync --extra dev --extra channels
uv run pytest
```
## Architecture
mizan-django is one of two reference backend adapters (the other is
`backends/mizan-fastapi`). Both implement the same Mizan protocol on top of
the shared `cores/mizan-python` core (`@client`, registry, MWT, HMAC cache
keys). See `docs/AFI_ARCHITECTURE.md`.

View File

@@ -1,10 +1,12 @@
[project] [project]
name = "djarea" name = "mizan"
version = "1.0.1" version = "1.0.1"
license = "Elastic-2.0"
description = "Django + React server functions framework" description = "Django + React server functions framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"mizan-core",
"django>=5.0", "django>=5.0",
"django-ninja>=1.0", "django-ninja>=1.0",
"django-readers>=2.0", "django-readers>=2.0",
@@ -12,7 +14,13 @@ dependencies = [
"PyJWT>=2.0", "PyJWT>=2.0",
] ]
[tool.uv.sources]
mizan-core = { path = "../../cores/mizan-python", editable = true }
[project.optional-dependencies] [project.optional-dependencies]
cache = [
"redis>=5.0",
]
channels = [ channels = [
"channels>=4.0", "channels>=4.0",
"channels-redis>=4.0", "channels-redis>=4.0",
@@ -36,11 +44,11 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/djarea"] packages = ["src/mizan"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings" DJANGO_SETTINGS_MODULE = "tests.settings"
pythonpath = ["src", "."] pythonpath = ["src", "."]
testpaths = ["src/djarea/tests"] testpaths = ["src/mizan/tests"]
python_classes = ["*Tests", "*Test", "Test*"] python_classes = ["*Tests", "*Test", "Test*"]
python_functions = ["test_*"] python_functions = ["test_*"]

View File

@@ -1,5 +1,5 @@
""" """
Djarea - Django + React unified framework mizan - Django + React unified framework
Server functions are the core primitive. Everything else builds on them. Server functions are the core primitive. Everything else builds on them.
@@ -7,16 +7,16 @@ Server functions are the core primitive. Everything else builds on them.
### 1. urls.py - HTTP endpoint ### 1. urls.py - HTTP endpoint
```python ```python
from djarea import urls as djarea_urls from mizan import urls as mizan_urls
urlpatterns = [ urlpatterns = [
path('api/djarea/', include(djarea_urls)), path('api/mizan/', include(mizan_urls)),
] ]
``` ```
### 2. asgi.py - WebSocket support (optional) ### 2. asgi.py - WebSocket support (optional)
```python ```python
from djarea import wrap_asgi from mizan import wrap_asgi
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
application = wrap_asgi(get_asgi_application()) application = wrap_asgi(get_asgi_application())
@@ -25,7 +25,7 @@ application = wrap_asgi(get_asgi_application())
### 3. Define server functions ### 3. Define server functions
```python ```python
# apps/myapp/clients.py # apps/myapp/clients.py
from djarea import client from mizan import client
from pydantic import BaseModel from pydantic import BaseModel
class EchoOutput(BaseModel): class EchoOutput(BaseModel):
@@ -51,8 +51,8 @@ def send_message(request, room_id: int, text: str) -> MessageOutput:
```python ```python
class MyAppConfig(AppConfig): class MyAppConfig(AppConfig):
def ready(self): def ready(self):
from djarea.setup import djarea_clients from mizan.setup import mizan_clients
djarea_clients('apps') mizan_clients('apps')
``` ```
### 5. Frontend - generate types and use ### 5. Frontend - generate types and use
@@ -76,7 +76,7 @@ await echo({ text: 'hello' })
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP | | `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
| `@client(websocket=True)` | `useXxx()` hook | WebSocket | | `@client(websocket=True)` | `useXxx()` hook | WebSocket |
| `@compose(...)` | `<XxxProvider>` combined | varies | | `@compose(...)` | `<XxxProvider>` combined | varies |
| `DjareaFormMixin` | `useXxxForm()` + Zod | HTTP | | `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
| `ReactChannel` | `useXxxChannel()` | WebSocket | | `ReactChannel` | `useXxxChannel()` | WebSocket |
""" """
@@ -88,12 +88,13 @@ from . import forms
from . import setup from . import setup
from .channels import ReactChannel from .channels import ReactChannel
from .channels import register as register_channel from .channels import register as register_channel
from .client import ComposedContext, ServerFunction, client, compose from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
# Shape is lazy-loaded via __getattr__ because django_readers
# imports contenttypes, which can't happen during apps.populate() # Shape is lazy-loaded via __getattr__ because django_readers
# imports contenttypes, which can't happen during apps.populate()
from .setup import ( from .setup import (
djarea_clients, mizan_clients,
djarea_module, mizan_module,
get_channel, get_channel,
get_function, get_function,
register, register,
@@ -104,9 +105,9 @@ from .setup import (
def __getattr__(name): def __getattr__(name):
"""Lazy loading for modules that can't be imported at app load time.""" """Lazy loading for modules that can't be imported at app load time."""
if name == "urls": if name == "urls":
from .urls import urlpatterns as djarea_patterns from .urls import urlpatterns as mizan_patterns
return djarea_patterns return mizan_patterns
if name == "Shape": if name == "Shape":
from .shapes import Shape from .shapes import Shape
@@ -116,11 +117,11 @@ def __getattr__(name):
def wrap_asgi(http_application): def wrap_asgi(http_application):
""" """
Wrap an ASGI application with Djarea WebSocket support. Wrap an ASGI application with mizan WebSocket support.
Usage in asgi.py: Usage in asgi.py:
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
from djarea import wrap_asgi from mizan import wrap_asgi
application = wrap_asgi(get_asgi_application()) application = wrap_asgi(get_asgi_application())
@@ -156,14 +157,16 @@ def wrap_asgi(http_application):
__all__ = [ __all__ = [
# Decorators # Decorators & Contexts
"client", "client",
"compose", "compose",
"ReactContext",
"GlobalContext",
"ServerFunction", "ServerFunction",
"ComposedContext", "ComposedContext",
# Setup # Setup
"djarea_clients", "mizan_clients",
"djarea_module", "mizan_module",
"register", "register",
"register_as", "register_as",
"get_function", "get_function",

View File

@@ -0,0 +1,40 @@
# Cache Module — Known Issues
Open issues against the current cache implementation. Resolved items are
removed once their fix lands.
## Correctness
### 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.
### 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.
## Performance / Operability
### 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.
### No thundering-herd protection
Concurrent cold misses on the same key all execute and write. No
single-flight / request-coalescing.
## API shape
### 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.
## Coverage
### RedisCache lacks test coverage
Only `MemoryCache` is exercised by the suite. `RedisCache` (connection
pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested.

View File

@@ -0,0 +1,142 @@
"""
mizan.cache — Origin-side cache implementing the Mizan cache protocol.
Simple key-value cache with HMAC-derived keys. No reverse indexes.
Scoped purge recomputes the key and deletes directly.
Broad purge uses key-prefix scan (rare operation).
Usage:
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
"""
from __future__ import annotations
import logging
import threading
from typing import Any
from mizan_core.cache.backend import CacheBackend, MemoryCache, RedisCache
from mizan_core.cache.keys import derive_cache_key, CONTEXT_KEY_PREFIX
logger = logging.getLogger("mizan.cache")
_cache_instance: CacheBackend | None = None
_initialized = False
_init_lock = threading.Lock()
def get_cache() -> CacheBackend | None:
"""
Get the configured cache backend, or None if caching is disabled.
Thread-safe.
"""
global _cache_instance, _initialized
if _initialized:
return _cache_instance
with _init_lock:
if _initialized:
return _cache_instance
_initialized = True
try:
from mizan.setup.settings import get_settings
settings = get_settings()
if settings.cache_secret and settings.cache_redis_url:
_cache_instance = RedisCache(settings.cache_redis_url)
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
elif settings.cache_secret and not settings.cache_redis_url:
logger.warning(
"MIZAN_CACHE_SECRET is set but MIZAN_CACHE_REDIS_URL is missing. "
"Cache is disabled."
)
elif settings.cache_redis_url and not settings.cache_secret:
logger.warning(
"MIZAN_CACHE_REDIS_URL is set but MIZAN_CACHE_SECRET is missing. "
"Cache is disabled."
)
except Exception:
logger.warning("Failed to initialize Mizan cache", exc_info=True)
_cache_instance = None
return _cache_instance
def set_cache(backend: CacheBackend | None) -> None:
"""Override the cache backend. For testing."""
global _cache_instance, _initialized
_cache_instance = backend
_initialized = True
def reset_cache() -> None:
"""Reset to uninitialized state. For testing teardown."""
global _cache_instance, _initialized
_cache_instance = None
_initialized = False
def cache_get(
secret: str,
backend: CacheBackend,
context: str,
params: dict[str, Any],
user_id: str | None = None,
rev: int = 0,
) -> bytes | None:
"""Look up a cached context response."""
key = derive_cache_key(secret, context, params, user_id, rev)
return backend.get(key)
def cache_put(
secret: str,
backend: CacheBackend,
context: str,
params: dict[str, Any],
value: bytes,
user_id: str | None = None,
rev: int = 0,
) -> None:
"""Store a context response in the cache."""
key = derive_cache_key(secret, context, params, user_id, rev)
backend.set(key, value)
def cache_purge(
backend: CacheBackend,
context: str,
params: dict[str, Any] | None = None,
secret: str | None = None,
user_id: str | None = None,
rev: int = 0,
) -> int:
"""
Purge cached entries for a context.
Scoped purge (params provided): recomputes the HMAC key and deletes
it directly. One DELETE, no index needed.
Broad purge (no params): scans by key prefix "ctx:{context}:*".
This is a rare operation (Tier 3 fallback in invalidation).
"""
if params is not None and len(params) > 0 and secret:
key = derive_cache_key(secret, context, params, user_id, rev)
return 1 if backend.delete(key) else 0
else:
prefix = f"{CONTEXT_KEY_PREFIX}{context}:"
return backend.delete_by_prefix(prefix)
__all__ = [
"CacheBackend",
"MemoryCache",
"RedisCache",
"get_cache",
"set_cache",
"reset_cache",
"cache_get",
"cache_put",
"cache_purge",
]

View File

@@ -1,5 +1,5 @@
""" """
djarea.channels - Real-time WebSocket communication. mizan.channels - Real-time WebSocket communication.
Type-safe bidirectional messaging between Django and React via WebSockets. Type-safe bidirectional messaging between Django and React via WebSockets.
Hooks are auto-generated with full TypeScript types. Hooks are auto-generated with full TypeScript types.
@@ -9,7 +9,7 @@ Hooks are auto-generated with full TypeScript types.
```python ```python
# channels.py # channels.py
from pydantic import BaseModel from pydantic import BaseModel
from djarea import channels from mizan import channels
class ChatChannel(channels.ReactChannel): class ChatChannel(channels.ReactChannel):
@@ -42,7 +42,7 @@ channels.register(ChatChannel, 'chat')
```python ```python
# asgi.py # asgi.py
from djarea import channels from mizan import channels
application = ProtocolTypeRouter({ application = ProtocolTypeRouter({
"http": get_asgi_application(), "http": get_asgi_application(),
@@ -88,6 +88,7 @@ logger = logging.getLogger(__name__)
# Base Classes # Base Classes
# ============================================================================= # =============================================================================
class ReactChannel: class ReactChannel:
""" """
Base class for WebSocket channels. Base class for WebSocket channels.
@@ -140,9 +141,7 @@ class ReactChannel:
Messages returned from receive() are broadcast to this group. Messages returned from receive() are broadcast to this group.
""" """
raise NotImplementedError( raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
f"{self.__class__.__name__} must implement group()"
)
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None: def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
""" """
@@ -191,9 +190,9 @@ class ReactChannel:
"type": "channel.message", "type": "channel.message",
"channel": self._registered_name, "channel": self._registered_name,
"params": self._params_dict, "params": self._params_dict,
"data": message.model_dump(mode='json'), "data": message.model_dump(mode="json"),
"message_type": message.__class__.__name__, "message_type": message.__class__.__name__,
} },
) )
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -215,7 +214,9 @@ class ReactChannel:
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
if not channel_layer: if not channel_layer:
logger.warning(f"No channel layer configured, cannot push to {cls.__name__}") logger.warning(
f"No channel layer configured, cannot push to {cls.__name__}"
)
return return
# Build params model if defined # Build params model if defined
@@ -234,9 +235,9 @@ class ReactChannel:
"type": "channel.message", "type": "channel.message",
"channel": cls._registered_name, "channel": cls._registered_name,
"params": params, "params": params,
"data": message.model_dump(mode='json'), "data": message.model_dump(mode="json"),
"message_type": message.__class__.__name__, "message_type": message.__class__.__name__,
} },
) )
@@ -261,9 +262,9 @@ def register(channel_class: Type[ReactChannel], name: str) -> None:
channel_class._registered_name = name channel_class._registered_name = name
# Validate the channel class # Validate the channel class
if not hasattr(channel_class, 'authorize'): if not hasattr(channel_class, "authorize"):
raise ValueError(f"{channel_class.__name__} must implement authorize()") raise ValueError(f"{channel_class.__name__} must implement authorize()")
if not hasattr(channel_class, 'group'): if not hasattr(channel_class, "group"):
raise ValueError(f"{channel_class.__name__} must implement group()") raise ValueError(f"{channel_class.__name__} must implement group()")
_registry[name] = channel_class _registry[name] = channel_class
@@ -284,12 +285,13 @@ def get_registered_channels() -> dict[str, Type[ReactChannel]]:
# WebSocket Consumer # WebSocket Consumer
# ============================================================================= # =============================================================================
def get_websocket_application(): def get_websocket_application():
""" """
Get the WebSocket application for ASGI. Get the WebSocket application for ASGI.
Usage in asgi.py: Usage in asgi.py:
from djarea import channels from mizan import channels
application = ProtocolTypeRouter({ application = ProtocolTypeRouter({
"http": get_asgi_application(), "http": get_asgi_application(),
@@ -309,9 +311,11 @@ def get_websocket_application():
from .connection import DjangoReactConsumer from .connection import DjangoReactConsumer
return AuthMiddlewareStack( return AuthMiddlewareStack(
URLRouter([ URLRouter(
path("ws/", DjangoReactConsumer.as_asgi()), [
]) path("ws/", DjangoReactConsumer.as_asgi()),
]
)
) )
@@ -319,15 +323,14 @@ def get_websocket_application():
# Schema Export (for TypeScript generation) # Schema Export (for TypeScript generation)
# ============================================================================= # =============================================================================
def get_channels_schema() -> dict: def get_channels_schema() -> dict:
""" """
Get schema for all registered channels (for TypeScript generation). Get schema for all registered channels (for TypeScript generation).
Returns a dict suitable for the frontend code generator. Returns a dict suitable for the frontend code generator.
""" """
schema = { schema = {"channels": {}}
"channels": {}
}
for name, channel_class in _registry.items(): for name, channel_class in _registry.items():
channel_schema = { channel_schema = {
@@ -338,16 +341,20 @@ def get_channels_schema() -> dict:
} }
# Extract Params schema # Extract Params schema
if hasattr(channel_class, 'Params') and channel_class.Params: if hasattr(channel_class, "Params") and channel_class.Params:
channel_schema["params"] = channel_class.Params.model_json_schema() channel_schema["params"] = channel_class.Params.model_json_schema()
# Extract ReactMessage schema # Extract ReactMessage schema
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage: if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
channel_schema["reactMessage"] = channel_class.ReactMessage.model_json_schema() channel_schema[
"reactMessage"
] = channel_class.ReactMessage.model_json_schema()
# Extract DjangoMessage schema # Extract DjangoMessage schema
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage: if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
channel_schema["djangoMessage"] = channel_class.DjangoMessage.model_json_schema() channel_schema[
"djangoMessage"
] = channel_class.DjangoMessage.model_json_schema()
schema["channels"][name] = channel_schema schema["channels"][name] = channel_schema
@@ -364,14 +371,19 @@ def _register_channel_schema_endpoint(
) -> None: ) -> None:
"""Register a dummy endpoint for schema generation (avoids closure issues).""" """Register a dummy endpoint for schema generation (avoids closure issues)."""
if input_cls is not None: if input_cls is not None:
def endpoint(request, data): def endpoint(request, data):
pass pass
endpoint.__annotations__ = {"data": input_cls} endpoint.__annotations__ = {"data": input_cls}
else: else:
def endpoint(request): def endpoint(request):
pass pass
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(endpoint) api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
endpoint
)
def get_channels_openapi_schema() -> dict: def get_channels_openapi_schema() -> dict:
@@ -386,9 +398,9 @@ def get_channels_openapi_schema() -> dict:
# Create temporary Ninja API for schema generation only # Create temporary Ninja API for schema generation only
schema_api = NinjaAPI( schema_api = NinjaAPI(
title="Djarea Channels", title="mizan Channels",
version="1.0.0", version="1.0.0",
description="Auto-generated schema for djarea channels", description="Auto-generated schema for mizan channels",
docs_url=None, docs_url=None,
openapi_url=None, openapi_url=None,
) )
@@ -409,7 +421,7 @@ def get_channels_openapi_schema() -> dict:
} }
# Register Params type # Register Params type
if hasattr(channel_class, 'Params') and channel_class.Params: if hasattr(channel_class, "Params") and channel_class.Params:
params_name = f"{pascal_name}Params" params_name = f"{pascal_name}Params"
schema_classes[params_name] = type(params_name, (channel_class.Params,), {}) schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
channel_meta["hasParams"] = True channel_meta["hasParams"] = True
@@ -426,9 +438,11 @@ def get_channels_openapi_schema() -> dict:
) )
# Register ReactMessage type # Register ReactMessage type
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage: if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
react_name = f"{pascal_name}ReactMessage" react_name = f"{pascal_name}ReactMessage"
schema_classes[react_name] = type(react_name, (channel_class.ReactMessage,), {}) schema_classes[react_name] = type(
react_name, (channel_class.ReactMessage,), {}
)
channel_meta["hasReactMessage"] = True channel_meta["hasReactMessage"] = True
channel_meta["reactMessageType"] = react_name channel_meta["reactMessageType"] = react_name
@@ -442,9 +456,11 @@ def get_channels_openapi_schema() -> dict:
) )
# Register DjangoMessage type # Register DjangoMessage type
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage: if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
django_name = f"{pascal_name}DjangoMessage" django_name = f"{pascal_name}DjangoMessage"
schema_classes[django_name] = type(django_name, (channel_class.DjangoMessage,), {}) schema_classes[django_name] = type(
django_name, (channel_class.DjangoMessage,), {}
)
channel_meta["hasDjangoMessage"] = True channel_meta["hasDjangoMessage"] = True
channel_meta["djangoMessageType"] = django_name channel_meta["djangoMessageType"] = django_name
@@ -464,7 +480,7 @@ def get_channels_openapi_schema() -> dict:
schema = schema_api.get_openapi_schema(path_prefix="") schema = schema_api.get_openapi_schema(path_prefix="")
# Add channel metadata extension # Add channel metadata extension
schema["x-djarea-channels"] = channel_metadata schema["x-mizan-channels"] = channel_metadata
return schema return schema
@@ -507,6 +523,47 @@ def __getattr__(name):
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# =============================================================================
# Core Registry Extension
# =============================================================================
class _ChannelsExtension:
"""
Plugs the channel registry into mizan_core.registry as the 'channels'
extension. Schema output goes under schema['channels'] in the unified
registry export consumed by codegen.
"""
def all(self) -> dict:
return dict(_registry)
def schema(self) -> dict:
out: dict[str, Any] = {}
for name, channel_class in _registry.items():
channel_schema: dict[str, Any] = {
"name": name,
"type": "channel",
"bidirectional": False,
}
if getattr(channel_class, "Params", None):
channel_schema["params"] = channel_class.Params.model_json_schema()
if getattr(channel_class, "ReactMessage", None):
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
channel_schema["bidirectional"] = True
if getattr(channel_class, "DjangoMessage", None):
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
out[name] = channel_schema
return out
def clear(self) -> None:
_registry.clear()
from mizan_core.registry import register_extension as _register_extension
_register_extension("channels", _ChannelsExtension())
# ============================================================================= # =============================================================================
# Exports # Exports
# ============================================================================= # =============================================================================

View File

@@ -1,5 +1,5 @@
""" """
WebSocket consumer for djarea.channels. WebSocket consumer for mizan.channels.
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection. Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
@@ -100,7 +100,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
await self._try_jwt_auth() await self._try_jwt_auth()
await self.accept() await self.accept()
logger.debug(f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}") logger.debug(
f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}"
)
async def _try_jwt_auth(self): async def _try_jwt_auth(self):
""" """
@@ -127,8 +129,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Validate JWT and create JWTUser (no DB query) # Validate JWT and create JWTUser (no DB query)
try: try:
from djarea.client.jwt import decode_token from mizan.client.jwt import decode_token
from djarea.jwt.tokens import JWTUser from mizan.jwt.tokens import JWTUser
payload = await sync_to_async(decode_token)(token, expected_type="access") payload = await sync_to_async(decode_token)(token, expected_type="access")
if payload is None: if payload is None:
@@ -166,9 +168,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
elif action == "rpc": elif action == "rpc":
await self._handle_rpc(content) await self._handle_rpc(content)
else: else:
await self.send_json({ await self.send_json(
"error": f"Unknown action: {action}", {
}) "error": f"Unknown action: {action}",
}
)
async def _handle_subscribe(self, content: dict): async def _handle_subscribe(self, content: dict):
"""Handle subscription request.""" """Handle subscription request."""
@@ -178,9 +182,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Get channel class # Get channel class
channel_class = get_channel(channel_name) channel_class = get_channel(channel_name)
if not channel_class: if not channel_class:
await self.send_json({ await self.send_json(
"error": f"Unknown channel: {channel_name}", {
}) "error": f"Unknown channel: {channel_name}",
}
)
return return
# Create subscription key # Create subscription key
@@ -189,11 +195,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Check if already subscribed # Check if already subscribed
if sub_key in self._subscriptions: if sub_key in self._subscriptions:
await self.send_json({ await self.send_json(
"error": f"Already subscribed to {channel_name}", {
"channel": channel_name, "error": f"Already subscribed to {channel_name}",
"params": params_dict, "channel": channel_name,
}) "params": params_dict,
}
)
return return
# Create channel instance # Create channel instance
@@ -210,10 +218,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
try: try:
params_obj = channel_class.Params(**params_dict) params_obj = channel_class.Params(**params_dict)
except Exception as e: except Exception as e:
await self.send_json({ await self.send_json(
"error": f"Invalid params: {e}", {
"channel": channel_name, "error": f"Invalid params: {e}",
}) "channel": channel_name,
}
)
return return
# Check authorization # Check authorization
@@ -224,17 +234,21 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
authorized = instance.authorize() authorized = instance.authorize()
except Exception as e: except Exception as e:
logger.error(f"Authorization error for {channel_name}: {e}") logger.error(f"Authorization error for {channel_name}: {e}")
await self.send_json({ await self.send_json(
"error": "Authorization failed", {
"channel": channel_name, "error": "Authorization failed",
}) "channel": channel_name,
}
)
return return
if not authorized: if not authorized:
await self.send_json({ await self.send_json(
"error": "Not authorized", {
"channel": channel_name, "error": "Not authorized",
}) "channel": channel_name,
}
)
return return
# Get group and join # Get group and join
@@ -246,10 +260,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
await instance._join_group(group_name) await instance._join_group(group_name)
except Exception as e: except Exception as e:
logger.error(f"Failed to join group for {channel_name}: {e}") logger.error(f"Failed to join group for {channel_name}: {e}")
await self.send_json({ await self.send_json(
"error": f"Failed to subscribe: {e}", {
"channel": channel_name, "error": f"Failed to subscribe: {e}",
}) "channel": channel_name,
}
)
return return
# Store subscription # Store subscription
@@ -262,11 +278,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
logger.error(f"on_connect error for {channel_name}: {e}") logger.error(f"on_connect error for {channel_name}: {e}")
# Confirm subscription # Confirm subscription
await self.send_json({ await self.send_json(
"subscribed": True, {
"channel": channel_name, "subscribed": True,
"params": params_dict, "channel": channel_name,
}) "params": params_dict,
}
)
logger.debug(f"Subscribed to {channel_name} with params {params_dict}") logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
@@ -286,11 +304,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
except Exception as e: except Exception as e:
logger.error(f"Error during unsubscribe: {e}") logger.error(f"Error during unsubscribe: {e}")
await self.send_json({ await self.send_json(
"unsubscribed": True, {
"channel": channel_name, "unsubscribed": True,
"params": params_dict, "channel": channel_name,
}) "params": params_dict,
}
)
logger.debug(f"Unsubscribed from {channel_name}") logger.debug(f"Unsubscribed from {channel_name}")
@@ -305,30 +325,36 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
instance = self._subscriptions.get(sub_key) instance = self._subscriptions.get(sub_key)
if not instance: if not instance:
await self.send_json({ await self.send_json(
"error": f"Not subscribed to {channel_name}", {
"channel": channel_name, "error": f"Not subscribed to {channel_name}",
}) "channel": channel_name,
}
)
return return
channel_class = instance.__class__ channel_class = instance.__class__
# Check if channel accepts messages # Check if channel accepts messages
if not channel_class.ReactMessage: if not channel_class.ReactMessage:
await self.send_json({ await self.send_json(
"error": f"Channel {channel_name} does not accept messages", {
"channel": channel_name, "error": f"Channel {channel_name} does not accept messages",
}) "channel": channel_name,
}
)
return return
# Parse message # Parse message
try: try:
msg = channel_class.ReactMessage(**data) msg = channel_class.ReactMessage(**data)
except Exception as e: except Exception as e:
await self.send_json({ await self.send_json(
"error": f"Invalid message: {e}", {
"channel": channel_name, "error": f"Invalid message: {e}",
}) "channel": channel_name,
}
)
return return
# Parse params # Parse params
@@ -351,10 +377,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
except Exception as e: except Exception as e:
logger.error(f"Error handling message for {channel_name}: {e}") logger.error(f"Error handling message for {channel_name}: {e}")
await self.send_json({ await self.send_json(
"error": f"Message handling failed: {e}", {
"channel": channel_name, "error": f"Message handling failed: {e}",
}) "channel": channel_name,
}
)
async def _handle_rpc(self, content: dict): async def _handle_rpc(self, content: dict):
""" """
@@ -371,8 +399,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
- Function must be explicitly registered (no arbitrary code execution) - Function must be explicitly registered (no arbitrary code execution)
- User context from WebSocket session is passed to function - User context from WebSocket session is passed to function
""" """
from djarea.client.executor import execute_function, FunctionError from mizan.client.executor import execute_function, FunctionError
from djarea.setup.registry import get_function from mizan_core.registry import get_function
request_id = content.get("id") request_id = content.get("id")
fn_name = content.get("fn") fn_name = content.get("fn")
@@ -380,50 +408,60 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Validate request structure # Validate request structure
if not request_id: if not request_id:
await self.send_json({ await self.send_json(
"error": "RPC request missing 'id' field", {
}) "error": "RPC request missing 'id' field",
}
)
return return
if not fn_name: if not fn_name:
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": "BAD_REQUEST", "error": {
"message": "Missing 'fn' field", "code": "BAD_REQUEST",
}, "message": "Missing 'fn' field",
}) },
}
)
return return
# Check if function exists and has websocket=True # Check if function exists and has websocket=True
fn_class = get_function(fn_name) fn_class = get_function(fn_name)
if fn_class is None: if fn_class is None:
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": "NOT_FOUND", "error": {
"message": f"Function '{fn_name}' not found", "code": "NOT_FOUND",
}, "message": f"Function '{fn_name}' not found",
}) },
}
)
return return
# Only allow functions explicitly marked with websocket=True # Only allow functions explicitly marked with websocket=True
fn_meta = getattr(fn_class, "_meta", {}) fn_meta = getattr(fn_class, "_meta", {})
if not fn_meta.get("websocket"): if not fn_meta.get("websocket"):
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": "FORBIDDEN", "error": {
"message": "This function is HTTP-only. Use POST /api/djarea/call/ instead.", "code": "FORBIDDEN",
}, "message": "This function is HTTP-only. Use POST /api/mizan/call/ instead.",
}) },
}
)
return return
# Create request adapter from WebSocket scope # Create request adapter from WebSocket scope
ws_request = WebSocketRequest(self.scope, channel_name=getattr(self, 'channel_name', None)) ws_request = WebSocketRequest(
self.scope, channel_name=getattr(self, "channel_name", None)
)
# Execute function (Pydantic validation happens inside execute_function) # Execute function (Pydantic validation happens inside execute_function)
# This is sync, so we need to run it in a thread pool # This is sync, so we need to run it in a thread pool
@@ -435,21 +473,25 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Send response # Send response
if isinstance(result, FunctionError): if isinstance(result, FunctionError):
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": result.code.value, "error": {
"message": result.message, "code": result.code.value,
**({"details": result.details} if result.details else {}), "message": result.message,
}, **({"details": result.details} if result.details else {}),
}) },
}
)
else: else:
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": True, "id": request_id,
"data": result.data, "ok": True,
}) "data": result.data,
}
)
async def channel_message(self, event: dict): async def channel_message(self, event: dict):
""" """
@@ -458,12 +500,14 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
Called when channel_layer.group_send() is used. Called when channel_layer.group_send() is used.
Includes channel name and params so the client can route the message. Includes channel name and params so the client can route the message.
""" """
await self.send_json({ await self.send_json(
"channel": event.get("channel"), {
"params": event.get("params", {}), "channel": event.get("channel"),
"type": event.get("message_type", "message"), "params": event.get("params", {}),
"data": event.get("data", {}), "type": event.get("message_type", "message"),
}) "data": event.get("data", {}),
}
)
async def push_message(self, event: dict): async def push_message(self, event: dict):
""" """
@@ -475,8 +519,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
Protocol: Protocol:
Server sends: {"type": "push", "topic": "room:42", "data": {...}} Server sends: {"type": "push", "topic": "room:42", "data": {...}}
""" """
await self.send_json({ await self.send_json(
"type": "push", {
"topic": event.get("topic"), "type": "push",
"data": event.get("data", {}), "topic": event.get("topic"),
}) "data": event.get("data", {}),
}
)

View File

@@ -1,16 +1,16 @@
""" """
Djarea Push - Server-initiated messages to clients. mizan Push - Server-initiated messages to clients.
Simple API for pushing data to subscribed WebSocket connections. Simple API for pushing data to subscribed WebSocket connections.
Usage: Usage:
# In a server function - push to all subscribers # In a server function - push to all subscribers
from djarea.push import push from mizan.push import push
push("room:42", {"type": "new_message", "data": {...}}) push("room:42", {"type": "new_message", "data": {...}})
# Subscribe a connection to a topic (call during context fetch) # Subscribe a connection to a topic (call during context fetch)
from djarea.push import subscribe from mizan.push import subscribe
subscribe(request, "room:42") subscribe(request, "room:42")
""" """
@@ -29,6 +29,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
"""Get channel layer, returning None if channels is not installed.""" """Get channel layer, returning None if channels is not installed."""
try: try:
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
return get_channel_layer() return get_channel_layer()
except ImportError: except ImportError:
return None return None
@@ -37,6 +38,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
def _async_to_sync(coro): def _async_to_sync(coro):
"""Wrapper for async_to_sync that handles missing channels.""" """Wrapper for async_to_sync that handles missing channels."""
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
return async_to_sync(coro) return async_to_sync(coro)
@@ -108,6 +110,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
channel_layer = _get_channel_layer() channel_layer = _get_channel_layer()
if not channel_layer: if not channel_layer:
import logging import logging
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"No channel layer configured, cannot push to topic '%s'", topic "No channel layer configured, cannot push to topic '%s'", topic
) )
@@ -125,7 +128,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
"type": "push.message", # Maps to push_message handler in consumer "type": "push.message", # Maps to push_message handler in consumer
"topic": topic, "topic": topic,
"data": data, "data": data,
} },
) )
@@ -146,5 +149,5 @@ async def push_async(topic: str, data: dict | BaseModel) -> None:
"type": "push.message", "type": "push.message",
"topic": topic, "topic": topic,
"data": data, "data": data,
} },
) )

View File

@@ -1,19 +1,30 @@
""" """
djarea.client - Server function implementation. mizan.client - Server function implementation.
This subpackage contains everything needed to make server functions work: This subpackage contains everything needed to make server functions work:
- The @client decorator - The @client decorator (lives in mizan_core.client.function)
- ServerFunction base class - ServerFunction base class (mizan_core.client.function)
- Function execution logic - Function execution logic (.executor Django-specific dispatch)
- JWT authentication (integral to server functions) - JWT authentication (.jwt Django-specific session integration)
Usage: Usage:
from djarea.client import client, ServerFunction, compose from mizan.client import client, ServerFunction, compose
""" """
from .function import ( # Register the Django framework response base so view-path detection works
# in mizan_core.client.function. Has to happen before any @client-decorated
# code is evaluated.
from django.http import HttpResponseBase as _HttpResponseBase
from mizan_core.client.function import set_framework_response_base as _set_response_base
_set_response_base(_HttpResponseBase)
from mizan_core.client.function import (
# Decorator # Decorator
client, client,
# Context markers
ReactContext,
GlobalContext,
# Base classes # Base classes
ServerFunction, ServerFunction,
ComposedContext, ComposedContext,
@@ -39,6 +50,9 @@ from .executor import (
__all__ = [ __all__ = [
# Decorator # Decorator
"client", "client",
# Context markers
"ReactContext",
"GlobalContext",
# Base classes # Base classes
"ServerFunction", "ServerFunction",
"ComposedContext", "ComposedContext",

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
""" """
djarea.client.jwt - JWT authentication for server functions. mizan.client.jwt - JWT authentication for server functions.
Provides: Provides:
- Server functions for obtaining/refreshing JWT tokens - Server functions for obtaining/refreshing JWT tokens
@@ -9,12 +9,12 @@ Server Functions:
- jwt_obtain: Convert authenticated session to JWT tokens - jwt_obtain: Convert authenticated session to JWT tokens
- jwt_refresh: Refresh tokens using a refresh token - jwt_refresh: Refresh tokens using a refresh token
Note: This module is purpose-built for Djarea server functions. Note: This module is purpose-built for mizan server functions.
For Django Ninja API authentication, use djarea.jwt.security directly. For Django Ninja API authentication, use mizan.jwt.security directly.
""" """
# Token utilities (re-exports from django_jwt_session) # Token utilities (re-exports from django_jwt_session)
from djarea.jwt.tokens import ( from mizan.jwt.tokens import (
create_token_pair, create_token_pair,
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
@@ -26,7 +26,7 @@ from djarea.jwt.tokens import (
) )
# Settings # Settings
from djarea.jwt.settings import get_settings, JWTSettings from mizan.jwt.settings import get_settings, JWTSettings
__all__ = [ __all__ = [
# Token utilities # Token utilities

View File

@@ -0,0 +1,156 @@
"""
Mizan Edge Manifest Generator.
Generates the Edge manifest — a static JSON mapping contexts to URL
patterns and params, consumed by Mizan Edge at deploy time for CDN
cache invalidation. Independent from the Mizan IR; the IR drives
codegen, the manifest drives CDN purging.
Usage:
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
"""
from __future__ import annotations
import json
import re
from typing import Any
from mizan_core.registry import get_context_groups, get_registry
__all__ = [
"generate_edge_manifest",
"generate_edge_manifest_json",
]
def generate_edge_manifest(
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
) -> dict[str, Any]:
"""
Generate the Edge manifest — a static JSON mapping contexts to URL
patterns and params for CDN cache purging.
The manifest is consumed by Mizan Edge at deploy time. When Edge
receives X-Mizan-Invalidate: user;user_id=5, it:
1. Looks up 'user' in the manifest
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
3. Purges the resolved URLs + the context API endpoint
Args:
base_url: The Mizan API mount point (default: /api/mizan)
view_urls: Optional mapping of context names to URL patterns for
view-path functions. These are URLs that Edge should
also purge when a context is invalidated.
Returns:
Manifest dict suitable for JSON serialization.
"""
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
groups = get_context_groups()
registry = get_registry()
all_functions = registry.get("functions", {})
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
for ctx_name, fn_names in sorted(groups.items()):
param_names: set[str] = set()
functions_meta: list[dict[str, Any]] = []
page_routes: list[str] = []
for fn_name in fn_names:
fn_cls = all_functions.get(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls is not None and hasattr(input_cls, "model_fields"):
for param_name in input_cls.model_fields:
param_names.add(param_name)
meta = getattr(fn_cls, "_meta", {})
route = meta.get("route")
view_path = meta.get("view_path")
fn_entry: dict[str, Any] = {
"name": fn_name,
"path": "view" if view_path else "rpc",
}
if route:
fn_entry["route"] = route
fn_entry["methods"] = meta.get("methods", ["GET"])
page_routes.append(route)
if meta.get("rev"):
fn_entry["rev"] = meta["rev"]
if meta.get("cache") is not None and meta.get("cache") is not True:
fn_entry["cache"] = meta["cache"]
functions_meta.append(fn_entry)
sorted_params = sorted(param_names)
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
ctx_entry: dict[str, Any] = {
"functions": functions_meta,
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
"params": sorted_params,
"user_scoped": user_scoped,
"render_strategy": "dynamic_cached" if user_scoped else "psr",
}
if page_routes:
ctx_entry["page_routes"] = page_routes
if view_urls and ctx_name in view_urls:
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
manifest["contexts"][ctx_name] = ctx_entry
for fn_name, fn_cls in sorted(all_functions.items()):
meta = getattr(fn_cls, "_meta", {})
if not meta.get("affects"):
continue
affected_contexts = list({a["name"] for a in meta["affects"]})
mutation: dict[str, Any] = {"affects": affected_contexts}
# Auto-scoped params — function params that match context params
input_cls = getattr(fn_cls, "Input", None)
if input_cls is not None and hasattr(input_cls, "model_fields"):
fn_params = set(input_cls.model_fields.keys())
auto_scoped: list[str] = []
for ctx_name in affected_contexts:
ctx_param_names: set[str] = set()
ctx_fns = groups.get(ctx_name, [])
for ctx_fn_name in ctx_fns:
ctx_fn_cls = all_functions.get(ctx_fn_name)
if ctx_fn_cls is None:
continue
ctx_input = getattr(ctx_fn_cls, "Input", None)
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
ctx_param_names.update(ctx_input.model_fields.keys())
for p in fn_params:
if p in ctx_param_names and p not in auto_scoped:
auto_scoped.append(p)
if auto_scoped:
mutation["auto_scoped_params"] = sorted(auto_scoped)
if meta.get("private"):
mutation["private"] = True
if meta.get("route"):
mutation["route"] = meta["route"]
mutation["methods"] = meta.get("methods", ["POST"])
manifest["mutations"][fn_name] = mutation
return manifest
def generate_edge_manifest_json(
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
indent: int = 2,
) -> str:
"""JSON-serialize the Edge manifest."""
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)

View File

@@ -1,16 +1,16 @@
""" """
DjareaFormMixin - Turn Django Forms into server functions. mizanFormMixin - Turn Django Forms into server functions.
This mixin transforms any Django Form into Djarea server functions, This mixin transforms any Django Form into mizan server functions,
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.) preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
while exposing them through the unified server function API. while exposing them through the unified server function API.
Usage: Usage:
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(DjareaFormMixin, forms.Form): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="contact", name="contact",
title="Contact Us", title="Contact Us",
submit_label="Send", submit_label="Send",
@@ -98,7 +98,7 @@ def _create_form_input_schema(
form = form_class() form = form_class()
except TypeError: except TypeError:
# Form requires extra args (like request) - use form_class.base_fields instead # Form requires extra args (like request) - use form_class.base_fields instead
fields_dict = getattr(form_class, 'base_fields', {}) fields_dict = getattr(form_class, "base_fields", {})
else: else:
fields_dict = form.fields fields_dict = form.fields
@@ -125,9 +125,9 @@ def _create_form_input_schema(
return model return model
class DjareaFormMeta(BaseModel): class mizanFormMeta(BaseModel):
""" """
Configuration for a Djarea form. Configuration for a mizan form.
This Pydantic model provides type-safe configuration with full LSP support, This Pydantic model provides type-safe configuration with full LSP support,
and serializes to JSON for the frontend schema. and serializes to JSON for the frontend schema.
@@ -167,14 +167,14 @@ class DjareaFormMeta(BaseModel):
enable_formset: bool = False enable_formset: bool = False
class DjareaFormMixin: class mizanFormMixin:
""" """
Mixin that exposes a Django Form as Djarea server functions. Mixin that exposes a Django Form as mizan server functions.
Add this mixin to any Django Form class along with a `djarea` configuration: Add this mixin to any Django Form class along with a `mizan` configuration:
class ContactForm(DjareaFormMixin, forms.Form): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="contact", name="contact",
title="Contact Us", title="Contact Us",
) )
@@ -197,10 +197,10 @@ class DjareaFormMixin:
""" """
# Configuration - subclasses must define this # Configuration - subclasses must define this
djarea: ClassVar[DjareaFormMeta] mizan: ClassVar[mizanFormMeta]
# Track registered forms to avoid duplicate registration # Track registered forms to avoid duplicate registration
_djarea_registered: ClassVar[bool] = False _mizan_registered: ClassVar[bool] = False
@classmethod @classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]: def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
@@ -236,9 +236,7 @@ class DjareaFormMixin:
return result return result
return None return None
def on_submit_failure( def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
self, request: HttpRequest, errors: "FormValidation"
) -> None:
""" """
Called after form validation fails. Called after form validation fails.
@@ -250,23 +248,23 @@ class DjareaFormMixin:
"""Auto-register when a concrete form class is defined.""" """Auto-register when a concrete form class is defined."""
super().__init_subclass__(**kwargs) super().__init_subclass__(**kwargs)
# Only register concrete forms with djarea config defined # Only register concrete forms with mizan config defined
if _is_concrete_djarea_form(cls): if _is_concrete_mizan_form(cls):
_register_form_as_server_functions(cls) _register_form_as_server_functions(cls)
def _is_concrete_djarea_form(cls: type) -> bool: def _is_concrete_mizan_form(cls: type) -> bool:
""" """
Check if a class is a concrete Djarea form ready for registration. Check if a class is a concrete mizan form ready for registration.
A form is concrete if: A form is concrete if:
1. It has a `djarea` attribute that is a DjareaFormMeta instance 1. It has a `mizan` attribute that is a mizanFormMeta instance
2. It inherits from Django's BaseForm 2. It inherits from Django's BaseForm
3. It hasn't been registered yet (for this class definition) 3. It hasn't been registered yet (for this class definition)
""" """
# Must have djarea config (check cls.__dict__ to avoid inheriting) # Must have mizan config (check cls.__dict__ to avoid inheriting)
djarea_config = cls.__dict__.get("djarea") mizan_config = cls.__dict__.get("mizan")
if not isinstance(djarea_config, DjareaFormMeta): if not isinstance(mizan_config, mizanFormMeta):
return False return False
# Must be a Django form # Must be a Django form
@@ -274,7 +272,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
return False return False
# Check if already registered (handle re-imports gracefully) # Check if already registered (handle re-imports gracefully)
if cls.__dict__.get("_djarea_registered", False): if cls.__dict__.get("_mizan_registered", False):
return False return False
return True return True
@@ -282,7 +280,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
def _register_form_as_server_functions(form_class: type) -> None: def _register_form_as_server_functions(form_class: type) -> None:
""" """
Register a Django Form class as Djarea server functions. Register a Django Form class as mizan server functions.
Creates and registers: Creates and registers:
- {name}.schema - Returns form field definitions - {name}.schema - Returns form field definitions
@@ -294,17 +292,20 @@ def _register_form_as_server_functions(form_class: type) -> None:
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
from .schema_utils import build_form_schema from .schema_utils import build_form_schema
from .validation_utils import validate_form_instance from .validation_utils import validate_form_instance
from djarea.setup.registry import register from mizan_core.registry import register
from djarea.client.function import ServerFunction from mizan_core.client.function import ServerFunction
config: DjareaFormMeta = form_class.djarea config: mizanFormMeta = form_class.mizan
form_name = config.name form_name = config.name
# Mark as registered # Mark as registered
form_class._djarea_registered = True form_class._mizan_registered = True
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact") # Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_')) pascal_name = "".join(
word.capitalize()
for word in form_name.replace(".", "_").replace("-", "_").split("_")
)
# NOTE: We cannot create FormDataSchema here because form fields aren't # NOTE: We cannot create FormDataSchema here because form fields aren't
# populated yet during __init_subclass__. We use lazy creation instead. # populated yet during __init_subclass__. We use lazy creation instead.
@@ -346,7 +347,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
data=input.data if input else {}, data=input.data if input else {},
**init_kwargs, **init_kwargs,
) )
# Override with DjareaFormMeta values # Override with mizanFormMeta values
if config.title is not None: if config.title is not None:
schema.title = config.title schema.title = config.title
if config.subtitle is not None: if config.subtitle is not None:
@@ -424,9 +425,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
request = self.request request = self.request
# Check if we have multipart data from executor # Check if we have multipart data from executor
if hasattr(request, "_djarea_form_data"): if hasattr(request, "_mizan_form_data"):
data = request._djarea_form_data data = request._mizan_form_data
files = request._djarea_form_files files = request._mizan_form_files
elif input is not None: elif input is not None:
# JSON input - already a dict # JSON input - already a dict
data = input if isinstance(input, dict) else input.model_dump() data = input if isinstance(input, dict) else input.model_dump()
@@ -474,17 +475,25 @@ def _register_formset_functions(
"""Register formset server functions for a form.""" """Register formset server functions for a form."""
from django.forms import formset_factory from django.forms import formset_factory
from .schemas import FormsetSchema, FormsetSubmitFail, FormsetSubmitPass, FormsetValidation from .schemas import (
FormsetSchema,
FormsetSubmitFail,
FormsetSubmitPass,
FormsetValidation,
)
from .schema_utils import build_form_schema from .schema_utils import build_form_schema
from .validation_utils import build_formset_validation from .validation_utils import build_formset_validation
from .formset_utils import forms_to_formset_post_data from .formset_utils import forms_to_formset_post_data
from djarea.setup.registry import register from mizan_core.registry import register
from djarea.client.function import ServerFunction from mizan_core.client.function import ServerFunction
formset_class = formset_factory(form_class) formset_class = formset_factory(form_class)
# Generate PascalCase name for schemas # Generate PascalCase name for schemas
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_')) pascal_name = "".join(
word.capitalize()
for word in form_name.replace(".", "_").replace("-", "_").split("_")
)
# NOTE: We cannot create typed schemas here because form fields aren't # NOTE: We cannot create typed schemas here because form fields aren't
# populated yet during __init_subclass__. We use generic dict inputs. # populated yet during __init_subclass__. We use generic dict inputs.
@@ -506,7 +515,7 @@ def _register_formset_functions(
"form": True, "form": True,
"form_name": form_name, "form_name": form_name,
"form_role": "formset_schema", "form_role": "formset_schema",
} }
def call(self, input) -> FormsetSchema: def call(self, input) -> FormsetSchema:
init_kwargs = form_class.get_init_kwargs(self.request) init_kwargs = form_class.get_init_kwargs(self.request)
@@ -590,10 +599,10 @@ def _register_formset_functions(
init_kwargs = form_class.get_init_kwargs(request) init_kwargs = form_class.get_init_kwargs(request)
# Handle multipart vs JSON # Handle multipart vs JSON
if hasattr(request, "_djarea_form_data"): if hasattr(request, "_mizan_form_data"):
post_data = request._djarea_form_data post_data = request._mizan_form_data
files = request._djarea_form_files files = request._mizan_form_files
elif input and hasattr(input, 'forms'): elif input and hasattr(input, "forms"):
# Input.forms is already a list of dicts # Input.forms is already a list of dicts
forms_data = input.forms forms_data = input.forms
post_data = forms_to_formset_post_data(forms_data) post_data = forms_to_formset_post_data(forms_data)
@@ -621,3 +630,48 @@ def _register_formset_functions(
FormsetSubmitFunction.__name__ = f"{form_name}_formset_submit" FormsetSubmitFunction.__name__ = f"{form_name}_formset_submit"
FormsetSubmitFunction.Output = FormsetSubmitPass FormsetSubmitFunction.Output = FormsetSubmitPass
register(FormsetSubmitFunction, f"{form_name}.formset.submit") register(FormsetSubmitFunction, f"{form_name}.formset.submit")
# ─── Public helpers ─────────────────────────────────────────────────────────
def register_form(
form_class: type,
name: str,
submit_handler: Any = None,
) -> None:
"""
Register a Django Form class as Mizan server functions.
Creates and registers `{name}.schema`, `{name}.validate`, and
`{name}.submit` (if a submit_handler is provided).
"""
from mizan_core.client.function import create_form_functions
from mizan_core.registry import register
schema_fn, validate_fn, submit_fn = create_form_functions(
form_class, name, submit_handler
)
register(schema_fn, f"{name}.schema")
register(validate_fn, f"{name}.validate")
if submit_fn:
register(submit_fn, f"{name}.submit")
def get_forms() -> dict[str, list]:
"""
Group registered form-related functions by their form name.
Returns a mapping like:
{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}
"""
from mizan_core.registry import get_all_functions
forms: dict[str, list] = {}
for name, cls in get_all_functions().items():
meta = getattr(cls, "_meta", {})
if not meta.get("form"):
continue
form_name = meta.get("form_name")
forms.setdefault(form_name, []).append(cls)
return forms

View File

@@ -1,7 +1,7 @@
""" """
Djarea Allauth Integration mizan Allauth Integration
Backend support for django-allauth with Djarea server functions. Backend support for django-allauth with mizan server functions.
Provides: Provides:
- Auth contexts (auth_status, user) - required by frontend allauth module - Auth contexts (auth_status, user) - required by frontend allauth module
@@ -11,8 +11,8 @@ Usage:
# In your app's apps.py # In your app's apps.py
class MyAppConfig(AppConfig): class MyAppConfig(AppConfig):
def ready(self): def ready(self):
import djarea.allauth.forms # noqa - registers forms import mizan.allauth.forms # noqa - registers forms
import djarea.allauth.contexts # noqa - registers contexts import mizan.allauth.contexts # noqa - registers contexts
""" """
from .contexts import auth_status, user, AuthStatusOutput, UserOutput from .contexts import auth_status, user, AuthStatusOutput, UserOutput

View File

@@ -1,5 +1,5 @@
""" """
Auth contexts for Djarea Allauth integration. Auth contexts for mizan Allauth integration.
These are the core auth primitives that the frontend allauth module depends on. These are the core auth primitives that the frontend allauth module depends on.
Separated into two concerns: Separated into two concerns:
@@ -13,7 +13,7 @@ Both are registered as global contexts for SSR hydration.
from django.http import HttpRequest from django.http import HttpRequest
from pydantic import BaseModel from pydantic import BaseModel
from djarea.client import client from mizan.client import client
# ============================================================================= # =============================================================================
@@ -23,13 +23,14 @@ from djarea.client import client
class AuthStatusOutput(BaseModel): class AuthStatusOutput(BaseModel):
"""Authentication status and permission guards.""" """Authentication status and permission guards."""
is_authenticated: bool is_authenticated: bool
user_id: int | None = None user_id: int | None = None
is_staff: bool = False is_staff: bool = False
is_superuser: bool = False is_superuser: bool = False
@client(context='global') @client(context="global")
def auth_status(request: HttpRequest) -> AuthStatusOutput: def auth_status(request: HttpRequest) -> AuthStatusOutput:
""" """
Auth status context - provides authentication state and guards. Auth status context - provides authentication state and guards.
@@ -62,13 +63,14 @@ def auth_status(request: HttpRequest) -> AuthStatusOutput:
class UserOutput(BaseModel): class UserOutput(BaseModel):
"""Full user profile data.""" """Full user profile data."""
id: int id: int
email: str email: str
first_name: str = "" first_name: str = ""
last_name: str = "" last_name: str = ""
@client(context='global') @client(context="global")
def user(request: HttpRequest) -> UserOutput | None: def user(request: HttpRequest) -> UserOutput | None:
""" """
User profile context - provides full user data. User profile context - provides full user data.
@@ -90,17 +92,18 @@ def user(request: HttpRequest) -> UserOutput | None:
return None return None
# Check if we have full user data or just JWT claims # Check if we have full user data or just JWT claims
if hasattr(req_user, 'email') and req_user.email: if hasattr(req_user, "email") and req_user.email:
# Full User object (session auth) # Full User object (session auth)
return UserOutput( return UserOutput(
id=req_user.id, id=req_user.id,
email=req_user.email, email=req_user.email,
first_name=getattr(req_user, 'first_name', '') or '', first_name=getattr(req_user, "first_name", "") or "",
last_name=getattr(req_user, 'last_name', '') or '', last_name=getattr(req_user, "last_name", "") or "",
) )
# JWTUser - need to fetch from DB # JWTUser - need to fetch from DB
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
try: try:
@@ -108,8 +111,8 @@ def user(request: HttpRequest) -> UserOutput | None:
return UserOutput( return UserOutput(
id=db_user.id, id=db_user.id,
email=db_user.email, email=db_user.email,
first_name=db_user.first_name or '', first_name=db_user.first_name or "",
last_name=db_user.last_name or '', last_name=db_user.last_name or "",
) )
except User.DoesNotExist: except User.DoesNotExist:
return None return None

View File

@@ -1,7 +1,7 @@
""" """
Allauth forms as Djarea server functions. Allauth forms as mizan server functions.
This module wraps allauth forms with DjareaFormMixin, exposing them as This module wraps allauth forms with mizanFormMixin, exposing them as
typed server functions for the React frontend. typed server functions for the React frontend.
Each form becomes three server functions: Each form becomes three server functions:
@@ -13,7 +13,7 @@ Import this module in your app's ready() to register the forms:
class MyAppConfig(AppConfig): class MyAppConfig(AppConfig):
def ready(self): def ready(self):
import djarea.allauth.forms # noqa import mizan.allauth.forms # noqa
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
from django.http import HttpRequest from django.http import HttpRequest
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
# Account forms # Account forms
from allauth.account.forms import ( from allauth.account.forms import (
@@ -41,6 +41,7 @@ from allauth.account.forms import (
# Password reauthentication form - conditionally import # Password reauthentication form - conditionally import
try: try:
from allauth.account.forms import ReauthenticateForm from allauth.account.forms import ReauthenticateForm
HAS_REAUTH = True HAS_REAUTH = True
except ImportError: except ImportError:
HAS_REAUTH = False HAS_REAUTH = False
@@ -51,6 +52,7 @@ try:
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
HAS_MFA = True HAS_MFA = True
except ImportError: except ImportError:
HAS_MFA = False HAS_MFA = False
@@ -58,22 +60,24 @@ except ImportError:
# WebAuthn forms (if available) # WebAuthn forms (if available)
try: try:
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
HAS_WEBAUTHN = True HAS_WEBAUTHN = True
except ImportError: except ImportError:
HAS_WEBAUTHN = False HAS_WEBAUTHN = False
if TYPE_CHECKING: if TYPE_CHECKING:
from djarea.forms.schemas import FormValidation from mizan.forms.schemas import FormValidation
# ============================================================================= # =============================================================================
# Account Forms # Account Forms
# ============================================================================= # =============================================================================
class DjareaLoginForm(LoginForm, DjareaFormMixin):
class mizanLoginForm(LoginForm, mizanFormMixin):
"""Sign in with email and password.""" """Sign in with email and password."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="login", name="login",
title="Sign In", title="Sign In",
subtitle="Welcome back. Enter your credentials to continue.", subtitle="Welcome back. Enter your credentials to continue.",
@@ -90,10 +94,10 @@ class DjareaLoginForm(LoginForm, DjareaFormMixin):
return None return None
class DjareaSignupForm(SignupForm, DjareaFormMixin): class mizanSignupForm(SignupForm, mizanFormMixin):
"""Create a new account.""" """Create a new account."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="signup", name="signup",
title="Create Account", title="Create Account",
subtitle="Enter your details to get started.", subtitle="Enter your details to get started.",
@@ -109,10 +113,10 @@ class DjareaSignupForm(SignupForm, DjareaFormMixin):
return None return None
class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin): class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
"""Add another email address to your account.""" """Add another email address to your account."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="add_email", name="add_email",
title="Add Email Address", title="Add Email Address",
subtitle="Add another email address to your account.", subtitle="Add another email address to your account.",
@@ -128,10 +132,10 @@ class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
return None return None
class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin): class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
"""Change your account password.""" """Change your account password."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="change_password", name="change_password",
title="Change Password", title="Change Password",
subtitle="Update your password to keep your account secure.", subtitle="Update your password to keep your account secure.",
@@ -147,10 +151,10 @@ class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
return None return None
class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin): class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
"""Set a password for accounts created via social login.""" """Set a password for accounts created via social login."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="set_password", name="set_password",
title="Set Password", title="Set Password",
subtitle="Create a password for your account.", subtitle="Create a password for your account.",
@@ -166,10 +170,10 @@ class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
return None return None
class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin): class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
"""Request a password reset email.""" """Request a password reset email."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="reset_password", name="reset_password",
title="Reset Password", title="Reset Password",
subtitle="Enter your email address and we'll send you a link to reset your password.", subtitle="Enter your email address and we'll send you a link to reset your password.",
@@ -185,10 +189,10 @@ class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
return None return None
class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin): class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
"""Set a new password using a reset key.""" """Set a new password using a reset key."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="reset_password_from_key", name="reset_password_from_key",
title="Set New Password", title="Set New Password",
subtitle="Enter your new password below.", subtitle="Enter your new password below.",
@@ -204,10 +208,10 @@ class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
return None return None
class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin): class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
"""Request a login code via email.""" """Request a login code via email."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="request_login_code", name="request_login_code",
title="Sign In with Code", title="Sign In with Code",
subtitle="Enter your email address and we'll send you a login code.", subtitle="Enter your email address and we'll send you a login code.",
@@ -223,10 +227,10 @@ class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
return None return None
class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin): class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
"""Confirm a login code.""" """Confirm a login code."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="confirm_login_code", name="confirm_login_code",
title="Enter Code", title="Enter Code",
subtitle="Enter the code we sent to your email.", subtitle="Enter the code we sent to your email.",
@@ -242,10 +246,10 @@ class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
return None return None
class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin): class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
"""Verify an email with a token.""" """Verify an email with a token."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="user_token", name="user_token",
title="Verify Email", title="Verify Email",
subtitle="Enter the verification code from your email.", subtitle="Enter the verification code from your email.",
@@ -263,10 +267,11 @@ class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
# Password reauthentication - conditionally define # Password reauthentication - conditionally define
if HAS_REAUTH: if HAS_REAUTH:
class DjareaReauthenticateForm(ReauthenticateForm, DjareaFormMixin):
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
"""Re-authenticate with password for sensitive actions.""" """Re-authenticate with password for sensitive actions."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="reauthenticate", name="reauthenticate",
title="Confirm Your Identity", title="Confirm Your Identity",
subtitle="Please enter your password to continue.", subtitle="Please enter your password to continue.",
@@ -280,6 +285,7 @@ if HAS_REAUTH:
def on_submit_success(self, request: HttpRequest) -> dict | None: def on_submit_success(self, request: HttpRequest) -> dict | None:
from allauth.account.internal.flows import reauthentication from allauth.account.internal.flows import reauthentication
reauthentication.reauthenticate_by_password(request) reauthentication.reauthenticate_by_password(request)
return None return None
@@ -289,10 +295,11 @@ if HAS_REAUTH:
# ============================================================================= # =============================================================================
if HAS_MFA: if HAS_MFA:
class DjareaMFAAuthenticateForm(MFAAuthenticateForm, DjareaFormMixin):
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
"""Authenticate with MFA during login.""" """Authenticate with MFA during login."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="mfa_authenticate", name="mfa_authenticate",
title="Two-Factor Authentication", title="Two-Factor Authentication",
subtitle="Enter your authentication code to continue.", subtitle="Enter your authentication code to continue.",
@@ -307,10 +314,10 @@ if HAS_MFA:
self.save() self.save()
return None return None
class DjareaMFAReauthenticateForm(MFAReauthenticateForm, DjareaFormMixin): class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
"""Re-authenticate with MFA for sensitive actions.""" """Re-authenticate with MFA for sensitive actions."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="mfa_reauthenticate", name="mfa_reauthenticate",
title="Confirm Your Identity", title="Confirm Your Identity",
subtitle="Enter your authentication code to continue.", subtitle="Enter your authentication code to continue.",
@@ -325,10 +332,10 @@ if HAS_MFA:
self.save() self.save()
return None return None
class DjareaActivateTOTPForm(ActivateTOTPForm, DjareaFormMixin): class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
"""Activate TOTP authenticator.""" """Activate TOTP authenticator."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="activate_totp", name="activate_totp",
title="Set Up Authenticator", title="Set Up Authenticator",
subtitle="Enter the code from your authenticator app to complete setup.", subtitle="Enter the code from your authenticator app to complete setup.",
@@ -343,10 +350,10 @@ if HAS_MFA:
self.save() self.save()
return None return None
class DjareaDeactivateTOTPForm(DeactivateTOTPForm, DjareaFormMixin): class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
"""Deactivate TOTP authenticator.""" """Deactivate TOTP authenticator."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="deactivate_totp", name="deactivate_totp",
title="Disable Authenticator", title="Disable Authenticator",
subtitle="Enter your password to disable two-factor authentication.", subtitle="Enter your password to disable two-factor authentication.",
@@ -361,10 +368,10 @@ if HAS_MFA:
self.save() self.save()
return None return None
class DjareaGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, DjareaFormMixin): class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
"""Generate new recovery codes.""" """Generate new recovery codes."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="generate_recovery_codes", name="generate_recovery_codes",
title="Recovery Codes", title="Recovery Codes",
subtitle="Generate new recovery codes for your account.", subtitle="Generate new recovery codes for your account.",
@@ -381,10 +388,11 @@ if HAS_MFA:
if HAS_WEBAUTHN: if HAS_WEBAUTHN:
class DjareaAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, DjareaFormMixin):
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
"""Authenticate with WebAuthn security key.""" """Authenticate with WebAuthn security key."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="webauthn_authenticate", name="webauthn_authenticate",
title="Security Key", title="Security Key",
subtitle="Use your security key to authenticate.", subtitle="Use your security key to authenticate.",

View File

@@ -1,5 +1,5 @@
""" """
djarea.jwt - JWT authentication for server functions. mizan.jwt - JWT authentication for server functions.
Provides: Provides:
- Server functions for obtaining/refreshing JWT tokens - Server functions for obtaining/refreshing JWT tokens
@@ -10,10 +10,10 @@ Server Functions:
- jwt_refresh: Refresh tokens using a refresh token - jwt_refresh: Refresh tokens using a refresh token
Usage in apps.py or urls.py (to register the functions): Usage in apps.py or urls.py (to register the functions):
import djarea.jwt.functions # noqa: F401 import mizan.jwt.functions # noqa: F401
Note: This module is purpose-built for Djarea server functions. Note: This module is purpose-built for mizan server functions.
For Django Ninja API authentication, use djarea.jwt.security directly. For Django Ninja API authentication, use mizan.jwt.security directly.
""" """
# Server functions (import to register with @client decorator) # Server functions (import to register with @client decorator)
@@ -36,12 +36,13 @@ from .settings import get_settings, JWTSettings
# Security (Ninja API auth) - lazy import to avoid triggering # Security (Ninja API auth) - lazy import to avoid triggering
# django-ninja's settings access at module load time. # django-ninja's settings access at module load time.
# Use: from djarea.jwt.security import jwt_auth # Use: from mizan.jwt.security import jwt_auth
def __getattr__(name): def __getattr__(name):
if name in ("JWTAuth", "jwt_auth"): if name in ("JWTAuth", "jwt_auth"):
from .security import JWTAuth, jwt_auth from .security import JWTAuth, jwt_auth
globals()["JWTAuth"] = JWTAuth globals()["JWTAuth"] = JWTAuth
globals()["jwt_auth"] = jwt_auth globals()["jwt_auth"] = jwt_auth
return globals()[name] return globals()[name]

View File

@@ -1,19 +1,21 @@
""" """
JWT Server Functions JWT & MWT Server Functions
JWT token operations exposed as djarea server functions. Token operations exposed as mizan server functions.
Works over WebSocket RPC (primary) or HTTP fallback. Works over WebSocket RPC (primary) or HTTP fallback.
""" """
from django.http import HttpRequest from django.http import HttpRequest
from pydantic import BaseModel from pydantic import BaseModel
from djarea.client import client from mizan.client import client
from djarea.jwt.tokens import create_token_pair, refresh_tokens from mizan.jwt.tokens import create_token_pair, refresh_tokens
from mizan_core.mwt import create_mwt
class TokenPairOutput(BaseModel): class TokenPairOutput(BaseModel):
"""JWT token pair response.""" """JWT token pair response."""
access_token: str access_token: str
refresh_token: str refresh_token: str
expires_in: int expires_in: int
@@ -21,6 +23,7 @@ class TokenPairOutput(BaseModel):
class JWTError(BaseModel): class JWTError(BaseModel):
"""JWT operation error.""" """JWT operation error."""
error: str error: str
@@ -45,10 +48,12 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
raise PermissionError("Authentication required") raise PermissionError("Authentication required")
# Get session key - for WebSocket, this comes from the scope # Get session key - for WebSocket, this comes from the scope
session = getattr(request, 'session', None) session = getattr(request, "session", None)
if session is None: if session is None:
# WebSocket request adapter - session is a dict, not SessionBase # WebSocket request adapter - session is a dict, not SessionBase
session_key = getattr(request, '_scope', {}).get('session', {}).get('_session_key') session_key = (
getattr(request, "_scope", {}).get("session", {}).get("_session_key")
)
if not session_key: if not session_key:
raise PermissionError("No session available") raise PermissionError("No session available")
else: else:
@@ -61,8 +66,8 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
tokens = create_token_pair( tokens = create_token_pair(
user.pk, user.pk,
session_key, session_key,
is_staff=getattr(user, 'is_staff', False), is_staff=getattr(user, "is_staff", False),
is_superuser=getattr(user, 'is_superuser', False), is_superuser=getattr(user, "is_superuser", False),
) )
return TokenPairOutput( return TokenPairOutput(
@@ -95,3 +100,42 @@ def jwt_refresh(request: HttpRequest, refresh_token: str) -> TokenPairOutput:
refresh_token=tokens.refresh_token, refresh_token=tokens.refresh_token,
expires_in=tokens.expires_in, expires_in=tokens.expires_in,
) )
# ── MWT (Mizan Web Token) ──────────────────────────────────────────────
class MWTOutput(BaseModel):
"""MWT token response."""
token: str
expires_in: int
@client
def mwt_obtain(request: HttpRequest) -> MWTOutput:
"""
Obtain a Mizan Web Token from an authenticated session.
Requires session authentication (cookie-based login).
Returns an MWT for the X-Mizan-Token header stateless,
cache-aware authentication with permission staleness detection.
Usage (from frontend):
const { token, expires_in } = await call('mwt_obtain')
// Use token in X-Mizan-Token header
"""
user = request.user
if not user.is_authenticated:
raise PermissionError("Authentication required")
from mizan.setup.settings import get_settings
settings = get_settings()
if not settings.mwt_secret:
raise ValueError(
"MIZAN_MWT_SECRET is not configured. MWT requires a signing secret."
)
token = create_mwt(user, settings.mwt_secret, ttl=settings.mwt_ttl)
return MWTOutput(token=token, expires_in=settings.mwt_ttl)

View File

@@ -25,7 +25,7 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): def handle(self, *args, **options):
from djarea.channels import get_channels_openapi_schema from mizan.channels import get_channels_openapi_schema
schema = get_channels_openapi_schema() schema = get_channels_openapi_schema()

View File

@@ -1,12 +1,13 @@
""" """
Export Djarea Schema Export Edge Manifest
Management command to export the djarea OpenAPI schema for TypeScript code generation. Generates the static JSON manifest that Mizan Edge reads at deploy time
The schema is consumed by openapi-typescript for robust type generation. to configure CDN cache rules and invalidation routing.
Usage: Usage:
python manage.py export_djarea_schema # Output to stdout python manage.py export_edge_manifest
python manage.py export_djarea_schema --output schema.json # Output to file python manage.py export_edge_manifest --output mizan-manifest.json
python manage.py export_edge_manifest --base-url /api/mizan
""" """
import json import json
@@ -14,11 +15,11 @@ from pathlib import Path
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from djarea.export import generate_openapi_schema from mizan.export import generate_edge_manifest
class Command(BaseCommand): class Command(BaseCommand):
help = "Export djarea OpenAPI schema for TypeScript code generation" help = "Export Edge manifest for CDN cache invalidation"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
@@ -34,18 +35,22 @@ class Command(BaseCommand):
default=2, default=2,
help="JSON indentation level (0 for compact output)", help="JSON indentation level (0 for compact output)",
) )
parser.add_argument(
"--base-url",
type=str,
default="/api/mizan",
help="Mizan API mount point (default: /api/mizan)",
)
def handle(self, *args, **options): def handle(self, *args, **options):
schema = generate_openapi_schema() manifest = generate_edge_manifest(base_url=options["base_url"])
indent = options["indent"] if options["indent"] > 0 else None indent = options["indent"] if options["indent"] > 0 else None
json_output = json.dumps(schema, indent=indent) json_output = json.dumps(manifest, indent=indent, sort_keys=True)
if options["output"]: if options["output"]:
output_path = Path(options["output"]) output_path = Path(options["output"])
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json_output) output_path.write_text(json_output)
self.stdout.write( self.stdout.write(self.style.SUCCESS(f"Manifest written to {output_path}"))
self.style.SUCCESS(f"Schema written to {output_path}")
)
else: else:
self.stdout.write(json_output) self.stdout.write(json_output)

View File

@@ -0,0 +1,28 @@
"""
Mizan IR (KDL) export — Django management command.
Usage:
python manage.py export_mizan_ir
Triggers Mizan client discovery to populate the registry, then writes
the canonical Mizan IR as KDL to stdout. The Rust codegen binary
consumes this directly.
"""
from __future__ import annotations
from django.core.management.base import BaseCommand
from mizan_core.ir import build_ir
class Command(BaseCommand):
help = "Export every registered @client function as Mizan IR (KDL)."
def handle(self, *args, **options) -> None:
# Load every project-side @client function so the registry is
# populated before we emit. Conventionally apps/*/clients.py.
from mizan.setup.discovery import mizan_clients
mizan_clients("apps")
self.stdout.write(build_ir(), ending="")

View File

@@ -1,41 +1,47 @@
""" """
djarea.setup - Integration and registration utilities. mizan.setup - Django integration helpers.
This subpackage contains everything developers need to integrate Djarea: The function/composition registry now lives in `mizan_core.registry`.
- Registry for server functions and channels Channels register themselves through the channel-specific registry in
- Auto-discovery for apps `mizan.channels`. Forms register through `mizan.forms`. This module
- Configuration settings re-exports the helpers that Django mizan users typically reach for, so
`from mizan.setup import register, get_function, mizan_clients, ` keeps
Usage: working as a single curated surface.
from djarea.setup import djarea_clients, register, get_function
""" """
from .registry import ( from mizan_core.registry import (
register, register,
register_as, register_as,
register_form,
register_compose, register_compose,
get_function, get_function,
get_channel,
get_compose, get_compose,
get_view,
get_all_functions, get_all_functions,
get_all_channels,
get_all_compositions, get_all_compositions,
get_registry, get_registry,
get_schema, get_schema,
get_contexts, get_contexts,
get_forms, get_context_groups,
validate_registry,
clear_registry, clear_registry,
) )
from mizan.channels import (
get_channel,
get_registered_channels as get_all_channels,
)
from mizan.forms import (
register_form,
get_forms,
)
from .discovery import ( from .discovery import (
djarea_clients, mizan_clients,
djarea_module, mizan_module,
) )
from .settings import ( from .settings import (
DjareaSettings, mizanSettings,
get_settings, get_settings,
clear_settings_cache, clear_settings_cache,
) )
@@ -50,20 +56,21 @@ __all__ = [
"get_function", "get_function",
"get_channel", "get_channel",
"get_compose", "get_compose",
"get_view",
"get_all_functions", "get_all_functions",
"get_all_channels", "get_all_channels",
"get_all_compositions", "get_all_compositions",
"get_registry", "get_registry",
"get_schema", "get_schema",
"get_contexts", "get_contexts",
"get_context_groups",
"get_forms", "get_forms",
"validate_registry",
"clear_registry", "clear_registry",
# Discovery # Discovery
"djarea_clients", "mizan_clients",
"djarea_module", "mizan_module",
# Settings # Settings
"DjareaSettings", "mizanSettings",
"get_settings", "get_settings",
"clear_settings_cache", "clear_settings_cache",
] ]

View File

@@ -1,25 +1,25 @@
""" """
Djarea Auto-Discovery mizan Auto-Discovery
Scans Django apps for server functions following the 'clients' layer convention: Scans Django apps for server functions following the 'clients' layer convention:
- <app>/clients.py - <app>/clients.py
- <app>/clients/**/*.py - <app>/clients/**/*.py
Usage in urls.py: Usage in urls.py:
from djarea.setup.discovery import djarea_clients from mizan.setup.discovery import mizan_clients
djarea_clients('apps') # Scans apps/*/clients.py mizan_clients('apps') # Scans apps/*/clients.py
djarea_clients('djarea', 'allauth') # Scans djarea/allauth/**/*.py mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
This replaces manual "import to register" patterns with explicit auto-discovery. This replaces manual "import to register" patterns with explicit auto-discovery.
""" """
from typing import Any from typing import Any
from djarea._vendor.app_visitor import DjangoAppVisitor, get_members from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
from .registry import register, get_function from mizan_core.registry import register, get_function
from djarea.client.function import ServerFunction from mizan_core.client.function import ServerFunction
class _RegisterServerFunctions: class _RegisterServerFunctions:
@@ -35,10 +35,10 @@ class _RegisterServerFunctions:
isinstance(member, type) isinstance(member, type)
and issubclass(member, ServerFunction) and issubclass(member, ServerFunction)
and member is not ServerFunction and member is not ServerFunction
and hasattr(member, '__name__') and hasattr(member, "__name__")
): ):
# Use the function name as registration name # Use the function name as registration name
fn_name = getattr(member, 'name', None) or member.__name__ fn_name = getattr(member, "name", None) or member.__name__
# Skip already registered (idempotent) # Skip already registered (idempotent)
if get_function(fn_name) is member: if get_function(fn_name) is member:
@@ -51,7 +51,7 @@ class _RegisterServerFunctions:
pass pass
def djarea_clients(apps_root: str, layer: str = 'clients') -> None: def mizan_clients(apps_root: str, layer: str = "clients") -> None:
""" """
Discover and register server functions from Django apps. Discover and register server functions from Django apps.
@@ -65,26 +65,26 @@ def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
Example: Example:
# In urls.py # In urls.py
djarea_clients('apps') # Scans apps/*/clients.py mizan_clients('apps') # Scans apps/*/clients.py
djarea_clients('apps', 'functions') # Scans apps/*/functions.py mizan_clients('apps', 'functions') # Scans apps/*/functions.py
""" """
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root) visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
visitor.visit(_RegisterServerFunctions()) visitor.visit(_RegisterServerFunctions())
def djarea_module(module_path: str) -> None: def mizan_module(module_path: str) -> None:
""" """
Register server functions from a specific module. Register server functions from a specific module.
Use this for library modules that don't follow the app convention. Use this for library modules that don't follow the app convention.
Args: Args:
module_path: Full module path (e.g., 'djarea.integrations.allauth') module_path: Full module path (e.g., 'mizan.integrations.allauth')
Example: Example:
djarea_module('djarea.integrations.allauth') mizan_module('mizan.integrations.allauth')
djarea_module('djarea.jwt.functions') mizan_module('mizan.jwt.functions')
""" """
members = get_members(module_path) members = get_members(module_path)
handler = _RegisterServerFunctions() handler = _RegisterServerFunctions()
handler.on_module('', [], members) handler.on_module("", [], members)

View File

@@ -0,0 +1,49 @@
"""
mizan Settings
Configuration is read from Django settings with sensible defaults.
"""
from dataclasses import dataclass
from functools import lru_cache
from django.conf import settings as django_settings
@dataclass
class mizanSettings:
"""mizan configuration."""
# Cache HMAC signing secret (required when cache is enabled)
cache_secret: str | None
# Redis URL for cache backend (None = cache disabled)
cache_redis_url: str | None
# MWT signing secret (separate from cache secret for blast radius containment)
mwt_secret: str | None
# MWT token lifetime in seconds (default: 300 = 5 minutes)
mwt_ttl: int
@lru_cache
def get_settings() -> mizanSettings:
"""
Load mizan settings from Django settings.
Settings:
MIZAN_CACHE_SECRET: HMAC signing key for cache keys (default: None)
MIZAN_CACHE_REDIS_URL: Redis connection URL (default: None)
"""
return mizanSettings(
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),
mwt_ttl=getattr(django_settings, "MIZAN_MWT_TTL", 300),
)
def clear_settings_cache():
"""Clear the settings cache (for testing)."""
get_settings.cache_clear()

View File

@@ -0,0 +1,3 @@
from mizan.shapes.core import Diff, NestedDiff, Shape
__all__ = ["Diff", "NestedDiff", "Shape"]

View File

@@ -0,0 +1,25 @@
"""
mizan.ssr — Server-side rendering via Bun subprocess.
Mizan's SSR is a Django template backend. Configure it in TEMPLATES:
TEMPLATES = [
{
'BACKEND': 'mizan.ssr.MizanTemplates',
'OPTIONS': {
'worker_path': 'frontend/ssr-worker.tsx',
'timeout': 5,
},
},
]
Then use Django's standard render():
return render(request, 'ProfilePage', {'user_id': 5})
The component name is the template name. The context dict becomes props.
"""
from .backend import MizanTemplates
__all__ = ["MizanTemplates"]

View File

@@ -0,0 +1,100 @@
"""
Mizan SSR Template Backend — Django template engine that renders React via Bun.
TEMPLATES = [
{
'BACKEND': 'mizan.ssr.MizanTemplates',
'DIRS': [BASE_DIR / 'frontend'],
'OPTIONS': {
'worker': 'path/to/mizan-ssr/src/worker.tsx',
},
},
]
Then: render(request, 'components/Hello.tsx', {'name': 'World'})
"""
from __future__ import annotations
import os
from typing import Any
from django.template import TemplateDoesNotExist
from django.template.backends.base import BaseEngine
from django.utils.safestring import mark_safe
from .bridge import SSRBridge
class MizanTemplate:
"""Renders a .tsx/.jsx file via the SSR bridge."""
def __init__(self, file_path: str, bridge: SSRBridge) -> None:
self.file_path = file_path
self.origin = None
self._bridge = bridge
def render(self, context: dict[str, Any] | None = None, request: Any = None) -> str:
import json as _json
props = dict(context) if context else {}
props.pop("request", None)
props.pop("csrf_token", None)
result = self._bridge.render(self.file_path, props)
# Serialize props as hydration data for client-side React
hydration_json = _json.dumps(props, sort_keys=True, default=str)
return mark_safe(
f'<div id="mizan-root">{result.html}</div>'
f'<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>'
)
class MizanTemplates(BaseEngine):
"""
Django template backend that renders React components via Bun.
Template names are file paths resolved against DIRS.
Same model as Django's built-in template engines.
"""
def __init__(self, params: dict[str, Any]) -> None:
options = params.pop("OPTIONS", {})
params.setdefault("NAME", "mizan")
params.setdefault("APP_DIRS", False)
super().__init__(params)
self._worker = options.get("worker")
self._timeout = options.get("timeout", 5)
self._bridge: SSRBridge | None = None
if not self._worker:
raise ValueError(
"MizanTemplates requires OPTIONS['worker'] — "
"the path to mizan-ssr's worker.tsx"
)
def get_bridge(self) -> SSRBridge:
if self._bridge is None:
self._bridge = SSRBridge(
worker_path=self._worker,
timeout=self._timeout,
)
return self._bridge
def get_template(self, template_name: str) -> MizanTemplate:
for dir_path in self.dirs:
file_path = os.path.join(dir_path, template_name)
if os.path.isfile(file_path):
return MizanTemplate(
os.path.abspath(file_path),
self.get_bridge(),
)
raise TemplateDoesNotExist(template_name)
def from_string(self, template_code: str) -> MizanTemplate:
raise TemplateDoesNotExist(
"MizanTemplates renders .tsx files, not template strings."
)

View File

@@ -0,0 +1,181 @@
"""
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
Protocol: newline-delimited JSON-RPC over stdin/stdout.
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
Response: {"id": 1, "html": "<div>...</div>"}
The subprocess stays alive across requests. It is started on first use
and restarted automatically if it crashes.
"""
from __future__ import annotations
import atexit
import json
import logging
import subprocess
import threading
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("mizan.ssr")
@dataclass
class RenderResult:
"""Result of an SSR render call."""
html: str
class SSRBridge:
"""
Manages a persistent Bun subprocess for server-side rendering.
Thread-safe. Multiple Django workers can call render() concurrently.
Request-response matching via message IDs.
"""
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
self._worker_path = worker_path
self._timeout = timeout
self._proc: subprocess.Popen | None = None
self._lock = threading.Lock()
self._write_lock = threading.Lock() # Serializes stdin writes
self._counter = 0
self._pending: dict[int, threading.Event] = {}
self._results: dict[int, dict] = {}
self._reader_thread: threading.Thread | None = None
self._ready = threading.Event()
# Ensure cleanup on process exit
atexit.register(self.shutdown)
def _ensure_running(self) -> None:
"""Start the Bun subprocess if it's not running."""
if self._proc is not None and self._proc.poll() is None:
return
if self._proc is not None:
logger.warning("Bun SSR worker died (exit code %s), restarting", self._proc.returncode)
self._ready.clear()
self._proc = subprocess.Popen(
["bun", "run", self._worker_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self._reader_thread = threading.Thread(
target=self._read_responses, daemon=True, name="mizan-ssr-reader",
)
self._reader_thread.start()
# Wait for the "ready" signal from the worker
if not self._ready.wait(timeout=self._timeout):
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
self.shutdown()
raise TimeoutError("SSR worker failed to start")
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
def _read_responses(self) -> None:
"""Background thread that reads JSON responses from stdout."""
try:
for line in self._proc.stdout:
if isinstance(line, bytes):
line = line.decode("utf-8")
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
logger.warning("Malformed JSON from SSR worker: %s", line[:200])
continue
msg_id = msg.get("id")
# Ready signal (id=0)
if msg_id == 0 and msg.get("ready"):
self._ready.set()
continue
if msg_id is not None and msg_id in self._pending:
self._results[msg_id] = msg
self._pending[msg_id].set()
except Exception:
logger.warning("SSR reader thread exited", exc_info=True)
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
"""
Render a React component to HTML.
Args:
file: Absolute path to the .tsx/.jsx file to render.
props: Props to pass to the component.
Returns:
RenderResult with the HTML string.
Raises:
TimeoutError: If the render takes longer than the configured timeout.
RuntimeError: If the render fails.
"""
with self._lock:
self._ensure_running()
self._counter += 1
msg_id = self._counter
event = threading.Event()
self._pending[msg_id] = event
request = json.dumps({
"id": msg_id,
"method": "render",
"params": {"file": file, "props": props or {}},
}) + "\n"
# Serialize stdin writes to prevent interleaving from concurrent threads
with self._write_lock:
try:
self._proc.stdin.write(request.encode("utf-8"))
self._proc.stdin.flush()
except (BrokenPipeError, OSError) as e:
self._pending.pop(msg_id, None)
raise RuntimeError(f"SSR worker pipe broken: {e}")
if not event.wait(self._timeout):
self._pending.pop(msg_id, None)
raise TimeoutError(
f"SSR render of '{file}' timed out after {self._timeout}s"
)
self._pending.pop(msg_id, None)
result = self._results.pop(msg_id)
if "error" in result:
raise RuntimeError(f"SSR render failed: {result['error']}")
return RenderResult(html=result["html"])
def shutdown(self) -> None:
"""Stop the Bun subprocess."""
if self._proc is not None:
try:
self._proc.stdin.close()
except Exception:
pass
try:
self._proc.terminate()
self._proc.wait(timeout=3)
except Exception:
try:
self._proc.kill()
except Exception:
pass
self._proc = None
logger.info("Bun SSR worker stopped")

View File

@@ -1,5 +1,5 @@
""" """
Authentication Tests for Djarea Server Functions Authentication Tests for mizan Server Functions
Tests all combinations of: Tests all combinations of:
- Transport: HTTP vs WebSocket RPC - Transport: HTTP vs WebSocket RPC
@@ -19,20 +19,20 @@ from django.contrib.sessions.backends.db import SessionStore
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import json import json
from djarea.jwt.tokens import ( from mizan.jwt.tokens import (
create_token_pair, create_token_pair,
decode_token, decode_token,
JWTUser, JWTUser,
) )
from djarea.client.executor import ( from mizan.client.executor import (
_try_jwt_auth, _try_jwt_auth,
execute_function, execute_function,
FunctionError, FunctionError,
FunctionResult, FunctionResult,
ErrorCode, ErrorCode,
) )
from djarea.client import client from mizan.client import client
from djarea.setup.registry import clear_registry, register from mizan_core.registry import clear_registry, register
from pydantic import BaseModel from pydantic import BaseModel
@@ -43,6 +43,7 @@ User = get_user_model()
# Test Output Models (proper Pydantic models, not raw dicts) # Test Output Models (proper Pydantic models, not raw dicts)
# ============================================================================= # =============================================================================
class WhoamiOutput(BaseModel): class WhoamiOutput(BaseModel):
is_authenticated: bool is_authenticated: bool
user_id: int | None user_id: int | None
@@ -62,6 +63,7 @@ class UserTypeOutput(BaseModel):
# Test Server Functions - defined as plain functions, registered in setUp # Test Server Functions - defined as plain functions, registered in setUp
# ============================================================================= # =============================================================================
def _whoami_fn(request) -> WhoamiOutput: def _whoami_fn(request) -> WhoamiOutput:
"""Returns info about the authenticated user.""" """Returns info about the authenticated user."""
user = request.user user = request.user
@@ -104,6 +106,7 @@ class HTTPAuthTests(TestCase):
user_type=type(user).__name__, user_type=type(user).__name__,
is_staff=getattr(user, "is_staff", False), is_staff=getattr(user, "is_staff", False),
) )
register(whoami, "whoami") register(whoami, "whoami")
def tearDown(self): def tearDown(self):
@@ -168,7 +171,7 @@ class HTTPAuthTests(TestCase):
def test_jwt_expired_with_session(self): def test_jwt_expired_with_session(self):
"""Expired JWT with valid session → Reject (do NOT fall back).""" """Expired JWT with valid session → Reject (do NOT fall back)."""
# Create token with past expiration by mocking time # Create token with past expiration by mocking time
with patch("djarea.jwt.tokens.time.time", return_value=0): with patch("mizan.jwt.tokens.time.time", return_value=0):
tokens = create_token_pair( tokens = create_token_pair(
self.user.pk, self.user.pk,
self.session_key, self.session_key,
@@ -248,7 +251,7 @@ class JWTUserTests(TestCase):
def test_jwt_user_attributes(self): def test_jwt_user_attributes(self):
"""JWTUser has expected attributes.""" """JWTUser has expected attributes."""
from djarea.jwt.tokens import TokenPayload from mizan.jwt.tokens import TokenPayload
payload = TokenPayload( payload = TokenPayload(
user_id=42, user_id=42,
@@ -272,7 +275,7 @@ class JWTUserTests(TestCase):
def test_jwt_user_string_id(self): def test_jwt_user_string_id(self):
"""JWTUser handles string user_id (converted to int).""" """JWTUser handles string user_id (converted to int)."""
from djarea.jwt.tokens import TokenPayload from mizan.jwt.tokens import TokenPayload
payload = TokenPayload( payload = TokenPayload(
user_id="42", # String, as stored in JWT user_id="42", # String, as stored in JWT
@@ -333,6 +336,7 @@ class AuthDecoratorTests(TestCase):
@client(auth=True) @client(auth=True)
def protected_fn(request) -> OkOutput: def protected_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(protected_fn, "protected_fn") register(protected_fn, "protected_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -345,9 +349,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_required_with_authenticated(self): def test_auth_required_with_authenticated(self):
"""@client(auth=True) allows authenticated users.""" """@client(auth=True) allows authenticated users."""
@client(auth=True) @client(auth=True)
def protected_fn2(request) -> OkOutput: def protected_fn2(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(protected_fn2, "protected_fn2") register(protected_fn2, "protected_fn2")
request = self.factory.post("/") request = self.factory.post("/")
@@ -360,9 +366,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_staff_with_regular_user(self): def test_auth_staff_with_regular_user(self):
"""@client(auth='staff') rejects non-staff users.""" """@client(auth='staff') rejects non-staff users."""
@client(auth='staff')
@client(auth="staff")
def staff_fn(request) -> OkOutput: def staff_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(staff_fn, "staff_fn") register(staff_fn, "staff_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -375,9 +383,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_staff_with_staff_user(self): def test_auth_staff_with_staff_user(self):
"""@client(auth='staff') allows staff users.""" """@client(auth='staff') allows staff users."""
@client(auth='staff')
@client(auth="staff")
def staff_fn2(request) -> OkOutput: def staff_fn2(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(staff_fn2, "staff_fn2") register(staff_fn2, "staff_fn2")
request = self.factory.post("/") request = self.factory.post("/")
@@ -389,9 +399,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_superuser_with_staff(self): def test_auth_superuser_with_staff(self):
"""@client(auth='superuser') rejects non-superusers.""" """@client(auth='superuser') rejects non-superusers."""
@client(auth='superuser')
@client(auth="superuser")
def super_fn(request) -> OkOutput: def super_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(super_fn, "super_fn") register(super_fn, "super_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -404,9 +416,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_superuser_with_superuser(self): def test_auth_superuser_with_superuser(self):
"""@client(auth='superuser') allows superusers.""" """@client(auth='superuser') allows superusers."""
@client(auth='superuser')
@client(auth="superuser")
def super_fn2(request) -> OkOutput: def super_fn2(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(super_fn2, "super_fn2") register(super_fn2, "super_fn2")
request = self.factory.post("/") request = self.factory.post("/")
@@ -418,11 +432,12 @@ class AuthDecoratorTests(TestCase):
def test_auth_with_jwt_user(self): def test_auth_with_jwt_user(self):
"""Auth checks work with JWTUser (stateless).""" """Auth checks work with JWTUser (stateless)."""
from djarea.jwt.tokens import TokenPayload from mizan.jwt.tokens import TokenPayload
@client(auth='staff') @client(auth="staff")
def jwt_staff_fn(request) -> UserTypeOutput: def jwt_staff_fn(request) -> UserTypeOutput:
return UserTypeOutput(user_type=type(request.user).__name__) return UserTypeOutput(user_type=type(request.user).__name__)
register(jwt_staff_fn, "jwt_staff_fn") register(jwt_staff_fn, "jwt_staff_fn")
# Create JWTUser with is_staff=True # Create JWTUser with is_staff=True
@@ -448,7 +463,8 @@ class AuthDecoratorTests(TestCase):
def test_auth_invalid_string_raises(self): def test_auth_invalid_string_raises(self):
"""Invalid auth string raises ValueError at decoration time.""" """Invalid auth string raises ValueError at decoration time."""
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
@client(auth='admin') # 'admin' is not valid
@client(auth="admin") # 'admin' is not valid
def bad_fn(request) -> OkOutput: def bad_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
@@ -457,9 +473,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_callable_returns_true(self): def test_auth_callable_returns_true(self):
"""Callable auth returning True allows access.""" """Callable auth returning True allows access."""
@client(auth=lambda r: r.user.email.endswith('@example.com'))
@client(auth=lambda r: r.user.email.endswith("@example.com"))
def email_check_fn(request) -> OkOutput: def email_check_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(email_check_fn, "email_check_fn") register(email_check_fn, "email_check_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -472,9 +490,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_callable_returns_false(self): def test_auth_callable_returns_false(self):
"""Callable auth returning False denies access.""" """Callable auth returning False denies access."""
@client(auth=lambda r: r.user.email.endswith('@admin.com'))
@client(auth=lambda r: r.user.email.endswith("@admin.com"))
def admin_email_fn(request) -> OkOutput: def admin_email_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(admin_email_fn, "admin_email_fn") register(admin_email_fn, "admin_email_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -488,14 +508,16 @@ class AuthDecoratorTests(TestCase):
def test_auth_callable_raises_permission_error(self): def test_auth_callable_raises_permission_error(self):
"""Callable auth raising PermissionError uses custom message.""" """Callable auth raising PermissionError uses custom message."""
def check_premium(request): def check_premium(request):
if not getattr(request.user, 'is_premium', False): if not getattr(request.user, "is_premium", False):
raise PermissionError("Premium subscription required") raise PermissionError("Premium subscription required")
return True return True
@client(auth=check_premium) @client(auth=check_premium)
def premium_fn(request) -> OkOutput: def premium_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(premium_fn, "premium_fn") register(premium_fn, "premium_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -519,6 +541,7 @@ class AuthDecoratorTests(TestCase):
@client(auth=must_be_authenticated) @client(auth=must_be_authenticated)
def needs_login_fn(request) -> OkOutput: def needs_login_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(needs_login_fn, "needs_login_fn") register(needs_login_fn, "needs_login_fn")
request = self.factory.post("/") request = self.factory.post("/")

View File

@@ -5,7 +5,7 @@ Compares performance of HTTP POST vs WebSocket RPC for server function calls.
Includes realistic scenarios with ORM queries. Includes realistic scenarios with ORM queries.
Usage: Usage:
python manage.py test djarea.tests.test_benchmarks --verbosity=2 python manage.py test mizan.tests.test_benchmarks --verbosity=2
Note: Note:
These are not unit tests - they measure performance. Results are printed These are not unit tests - they measure performance. Results are printed
@@ -26,9 +26,9 @@ from django.http import HttpRequest
from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
from pydantic import BaseModel from pydantic import BaseModel
from djarea.client.executor import FunctionResult, execute_function, function_call_view from mizan.client.executor import FunctionResult, execute_function, function_call_view
from djarea.setup.registry import clear_registry from mizan_core.registry import clear_registry
from djarea.client import client from mizan.client import client
User = get_user_model() User = get_user_model()
@@ -66,7 +66,7 @@ class StatsOutput(BaseModel):
def setup_benchmark_functions(): def setup_benchmark_functions():
"""Register benchmark server functions.""" """Register benchmark server functions."""
from djarea.setup.registry import register from mizan_core.registry import register
clear_registry() clear_registry()
@@ -75,6 +75,7 @@ def setup_benchmark_functions():
def bench_simple(request: HttpRequest, a: int, b: int) -> SimpleOutput: def bench_simple(request: HttpRequest, a: int, b: int) -> SimpleOutput:
"""Simple addition - baseline with no I/O.""" """Simple addition - baseline with no I/O."""
return SimpleOutput(value=a + b) return SimpleOutput(value=a + b)
register(bench_simple, "bench_simple") register(bench_simple, "bench_simple")
# 2. Single ORM query # 2. Single ORM query
@@ -85,6 +86,7 @@ def setup_benchmark_functions():
if user: if user:
return UserOutput(id=user.id, email=user.email) return UserOutput(id=user.id, email=user.email)
return UserOutput(id=0, email="") return UserOutput(id=0, email="")
register(bench_get_user, "bench_get_user") register(bench_get_user, "bench_get_user")
# 3. List query with limit # 3. List query with limit
@@ -96,6 +98,7 @@ def setup_benchmark_functions():
users=[{"id": u.id, "email": u.email} for u in users], users=[{"id": u.id, "email": u.email} for u in users],
count=len(users), count=len(users),
) )
register(bench_list_users, "bench_list_users") register(bench_list_users, "bench_list_users")
# 4. Aggregation query # 4. Aggregation query
@@ -110,11 +113,14 @@ def setup_benchmark_functions():
active_users=active, active_users=active,
staff_count=staff, staff_count=staff,
) )
register(bench_user_stats, "bench_user_stats") register(bench_user_stats, "bench_user_stats")
# 5. Complex query with joins # 5. Complex query with joins
@client @client
def bench_user_search(request: HttpRequest, email_contains: str, limit: int) -> UserListOutput: def bench_user_search(
request: HttpRequest, email_contains: str, limit: int
) -> UserListOutput:
"""Search users by email pattern.""" """Search users by email pattern."""
users = User.objects.filter( users = User.objects.filter(
email__icontains=email_contains, email__icontains=email_contains,
@@ -124,6 +130,7 @@ def setup_benchmark_functions():
users=[{"id": u.id, "email": u.email} for u in users], users=[{"id": u.id, "email": u.email} for u in users],
count=len(users), count=len(users),
) )
register(bench_user_search, "bench_user_search") register(bench_user_search, "bench_user_search")
@@ -158,11 +165,13 @@ class ProtocolBenchmark(TransactionTestCase):
# Create 100 test users # Create 100 test users
users = [] users = []
for i in range(100): for i in range(100):
users.append(User( users.append(
email=f"bench{i}@example.com", User(
is_active=i % 10 != 0, # 90% active email=f"bench{i}@example.com",
is_staff=i < 5, # 5 staff is_active=i % 10 != 0, # 90% active
)) is_staff=i < 5, # 5 staff
)
)
User.objects.bulk_create(users, ignore_conflicts=True) User.objects.bulk_create(users, ignore_conflicts=True)
self.test_user = User.objects.first() self.test_user = User.objects.first()
@@ -170,12 +179,12 @@ class ProtocolBenchmark(TransactionTestCase):
"""Create a request with optional JSON body.""" """Create a request with optional JSON body."""
if body: if body:
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data=json.dumps(body), data=json.dumps(body),
content_type="application/json", content_type="application/json",
) )
else: else:
request = self.factory.post("/api/djarea/call/") request = self.factory.post("/api/mizan/call/")
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
return request return request
@@ -245,12 +254,16 @@ class ProtocolBenchmark(TransactionTestCase):
print(f"{'Benchmark':<40} {'Mean':>8} {'Median':>8} {'P95':>8} {'P99':>8}") print(f"{'Benchmark':<40} {'Mean':>8} {'Median':>8} {'P95':>8} {'P99':>8}")
print("=" * 80) print("=" * 80)
for r in results: for r in results:
print(f"{r['label']:<40} {r['mean']:>7.3f}ms {r['median']:>7.3f}ms {r['p95']:>7.3f}ms {r['p99']:>7.3f}ms") print(
f"{r['label']:<40} {r['mean']:>7.3f}ms {r['median']:>7.3f}ms {r['p95']:>7.3f}ms {r['p99']:>7.3f}ms"
)
print("=" * 80) print("=" * 80)
def _print_comparison(self, executor_stats: dict, http_stats: dict): def _print_comparison(self, executor_stats: dict, http_stats: dict):
"""Print comparison between executor and HTTP.""" """Print comparison between executor and HTTP."""
overhead = ((http_stats["mean"] - executor_stats["mean"]) / executor_stats["mean"]) * 100 overhead = (
(http_stats["mean"] - executor_stats["mean"]) / executor_stats["mean"]
) * 100
print(f" HTTP overhead vs Executor: {overhead:+.1f}%") print(f" HTTP overhead vs Executor: {overhead:+.1f}%")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -400,18 +413,20 @@ class ThroughputBenchmark(TransactionTestCase):
"""Create test users for benchmarks.""" """Create test users for benchmarks."""
users = [] users = []
for i in range(100): for i in range(100):
users.append(User( users.append(
email=f"bench{i}@example.com", User(
is_active=i % 10 != 0, email=f"bench{i}@example.com",
is_staff=i < 5, is_active=i % 10 != 0,
)) is_staff=i < 5,
)
)
User.objects.bulk_create(users, ignore_conflicts=True) User.objects.bulk_create(users, ignore_conflicts=True)
self.test_user = User.objects.first() self.test_user = User.objects.first()
def _make_request(self, body: dict) -> HttpRequest: def _make_request(self, body: dict) -> HttpRequest:
"""Create a POST request with JSON body.""" """Create a POST request with JSON body."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data=json.dumps(body), data=json.dumps(body),
content_type="application/json", content_type="application/json",
) )
@@ -470,7 +485,9 @@ class ThroughputBenchmark(TransactionTestCase):
"""Throughput: Simple computation (no I/O).""" """Throughput: Simple computation (no I/O)."""
print("\n\n### THROUGHPUT: Simple Computation ###") print("\n\n### THROUGHPUT: Simple Computation ###")
executor_rps = self._measure_throughput_executor("bench_simple", {"a": 1, "b": 2}) executor_rps = self._measure_throughput_executor(
"bench_simple", {"a": 1, "b": 2}
)
http_rps = self._measure_throughput_http("bench_simple", {"a": 1, "b": 2}) http_rps = self._measure_throughput_http("bench_simple", {"a": 1, "b": 2})
self._print_throughput("Simple (no I/O)", executor_rps, http_rps) self._print_throughput("Simple (no I/O)", executor_rps, http_rps)
@@ -502,7 +519,9 @@ class ThroughputBenchmark(TransactionTestCase):
"""Throughput: List query.""" """Throughput: List query."""
print("\n\n### THROUGHPUT: List Query (10 users) ###") print("\n\n### THROUGHPUT: List Query (10 users) ###")
executor_rps = self._measure_throughput_executor("bench_list_users", {"limit": 10}) executor_rps = self._measure_throughput_executor(
"bench_list_users", {"limit": 10}
)
http_rps = self._measure_throughput_http("bench_list_users", {"limit": 10}) http_rps = self._measure_throughput_http("bench_list_users", {"limit": 10})
self._print_throughput("List Query", executor_rps, http_rps) self._print_throughput("List Query", executor_rps, http_rps)

View File

@@ -1,5 +1,5 @@
""" """
Tests for djarea.channels module. Tests for mizan.channels module.
""" """
import json import json
@@ -8,7 +8,7 @@ from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from pydantic import BaseModel from pydantic import BaseModel
from djarea.channels import ( from mizan.channels import (
ReactChannel, ReactChannel,
register, register,
get_channel, get_channel,
@@ -25,8 +25,10 @@ User = get_user_model()
# Test Fixtures # Test Fixtures
# ============================================================================= # =============================================================================
class MockUser: class MockUser:
"""Mock user for testing.""" """Mock user for testing."""
def __init__(self, is_authenticated=True, email="test@example.com"): def __init__(self, is_authenticated=True, email="test@example.com"):
self.is_authenticated = is_authenticated self.is_authenticated = is_authenticated
self.email = email self.email = email
@@ -34,6 +36,7 @@ class MockUser:
class MockAnonymousUser: class MockAnonymousUser:
"""Mock anonymous user.""" """Mock anonymous user."""
is_authenticated = False is_authenticated = False
email = "" email = ""
@@ -42,6 +45,7 @@ class MockAnonymousUser:
# ReactChannel Base Class Tests # ReactChannel Base Class Tests
# ============================================================================= # =============================================================================
class ReactChannelBaseTests(TestCase): class ReactChannelBaseTests(TestCase):
"""Tests for ReactChannel base class.""" """Tests for ReactChannel base class."""
@@ -115,6 +119,7 @@ class ReactChannelBaseTests(TestCase):
# Channel with Typed Messages Tests # Channel with Typed Messages Tests
# ============================================================================= # =============================================================================
class TypedMessagesTests(TestCase): class TypedMessagesTests(TestCase):
"""Tests for channels with Pydantic message types.""" """Tests for channels with Pydantic message types."""
@@ -179,9 +184,7 @@ class TypedMessagesTests(TestCase):
# Test message model # Test message model
msg = BroadcastChannel.DjangoMessage( msg = BroadcastChannel.DjangoMessage(
user="john", user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
text="Hello world",
created_at="2024-01-15T10:00:00Z"
) )
self.assertEqual(msg.user, "john") self.assertEqual(msg.user, "john")
self.assertEqual(msg.text, "Hello world") self.assertEqual(msg.text, "Hello world")
@@ -207,10 +210,7 @@ class TypedMessagesTests(TestCase):
return f"chat_{params.room}" return f"chat_{params.room}"
def receive(self, params, msg): def receive(self, params, msg):
return self.DjangoMessage( return self.DjangoMessage(user=self.user.email, text=msg.text)
user=self.user.email,
text=msg.text
)
channel = ChatChannel() channel = ChatChannel()
channel.user = MockUser(email="test@example.com") channel.user = MockUser(email="test@example.com")
@@ -229,6 +229,7 @@ class TypedMessagesTests(TestCase):
# Registration Tests # Registration Tests
# ============================================================================= # =============================================================================
class RegistrationTests(TestCase): class RegistrationTests(TestCase):
"""Tests for channel registration.""" """Tests for channel registration."""
@@ -336,6 +337,7 @@ class RegistrationTests(TestCase):
# Schema Export Tests # Schema Export Tests
# ============================================================================= # =============================================================================
class SchemaExportTests(TestCase): class SchemaExportTests(TestCase):
"""Tests for channel schema export.""" """Tests for channel schema export."""
@@ -482,6 +484,7 @@ class SchemaExportTests(TestCase):
# Authorization Tests # Authorization Tests
# ============================================================================= # =============================================================================
class AuthorizationTests(TestCase): class AuthorizationTests(TestCase):
"""Tests for channel authorization.""" """Tests for channel authorization."""
@@ -543,6 +546,7 @@ class AuthorizationTests(TestCase):
# Group Tests # Group Tests
# ============================================================================= # =============================================================================
class GroupTests(TestCase): class GroupTests(TestCase):
"""Tests for channel group management.""" """Tests for channel group management."""
@@ -586,6 +590,7 @@ class GroupTests(TestCase):
# Async Methods Tests # Async Methods Tests
# ============================================================================= # =============================================================================
class AsyncMethodsTests(TestCase): class AsyncMethodsTests(TestCase):
"""Tests for async internal methods.""" """Tests for async internal methods."""
@@ -727,6 +732,7 @@ class AsyncMethodsTests(TestCase):
# Server Push Tests # Server Push Tests
# ============================================================================= # =============================================================================
class ServerPushTests(TestCase): class ServerPushTests(TestCase):
"""Tests for server push functionality.""" """Tests for server push functionality."""
@@ -752,13 +758,12 @@ class ServerPushTests(TestCase):
def group(self, params=None): def group(self, params=None):
return "notifications" return "notifications"
with patch('channels.layers.get_channel_layer') as mock_get_layer: with patch("channels.layers.get_channel_layer") as mock_get_layer:
mock_layer = AsyncMock() mock_layer = AsyncMock()
mock_get_layer.return_value = mock_layer mock_get_layer.return_value = mock_layer
message = NotificationChannel.DjangoMessage( message = NotificationChannel.DjangoMessage(
title="Alert", title="Alert", body="Something happened"
body="Something happened"
) )
async def test(): async def test():
@@ -789,7 +794,7 @@ class ServerPushTests(TestCase):
def group(self, params): def group(self, params):
return f"room_{params.room}" return f"room_{params.room}"
with patch('channels.layers.get_channel_layer') as mock_get_layer: with patch("channels.layers.get_channel_layer") as mock_get_layer:
mock_layer = AsyncMock() mock_layer = AsyncMock()
mock_get_layer.return_value = mock_layer mock_get_layer.return_value = mock_layer
@@ -821,24 +826,28 @@ class ServerPushTests(TestCase):
def group(self, params=None): def group(self, params=None):
return "test" return "test"
with patch('channels.layers.get_channel_layer') as mock_get_layer: with patch("channels.layers.get_channel_layer") as mock_get_layer:
mock_get_layer.return_value = None mock_get_layer.return_value = None
message = TestChannel.DjangoMessage(text="test") message = TestChannel.DjangoMessage(text="test")
with self.assertLogs('djarea.channels', level='WARNING') as cm: with self.assertLogs("mizan.channels", level="WARNING") as cm:
async def test(): async def test():
await TestChannel.push(message=message) await TestChannel.push(message=message)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
self.assertTrue(any("No channel layer configured" in msg for msg in cm.output)) self.assertTrue(
any("No channel layer configured" in msg for msg in cm.output)
)
# ============================================================================= # =============================================================================
# Management Command Tests # Management Command Tests
# ============================================================================= # =============================================================================
class ManagementCommandTests(TestCase): class ManagementCommandTests(TestCase):
"""Tests for the export_channels_schema management command.""" """Tests for the export_channels_schema management command."""
@@ -855,7 +864,7 @@ class ManagementCommandTests(TestCase):
from django.core.management import call_command from django.core.management import call_command
out = StringIO() out = StringIO()
call_command('export_channels_schema', stdout=out) call_command("export_channels_schema", stdout=out)
output = out.getvalue() output = out.getvalue()
@@ -863,7 +872,7 @@ class ManagementCommandTests(TestCase):
schema = json.loads(output) schema = json.loads(output)
self.assertIn("openapi", schema) self.assertIn("openapi", schema)
self.assertIn("x-djarea-channels", schema) self.assertIn("x-mizan-channels", schema)
def test_export_command_includes_registered_channels(self): def test_export_command_includes_registered_channels(self):
"""export_channels_schema should include registered channels.""" """export_channels_schema should include registered channels."""
@@ -883,13 +892,13 @@ class ManagementCommandTests(TestCase):
register(TestChannel, "export-test") register(TestChannel, "export-test")
out = StringIO() out = StringIO()
call_command('export_channels_schema', stdout=out) call_command("export_channels_schema", stdout=out)
output = out.getvalue() output = out.getvalue()
schema = json.loads(output) schema = json.loads(output)
# Check that channel is in x-djarea-channels metadata # Check that channel is in x-mizan-channels metadata
channel_names = [c["name"] for c in schema["x-djarea-channels"]] channel_names = [c["name"] for c in schema["x-mizan-channels"]]
self.assertIn("export-test", channel_names) self.assertIn("export-test", channel_names)
def test_export_command_respects_indent(self): def test_export_command_respects_indent(self):
@@ -899,11 +908,11 @@ class ManagementCommandTests(TestCase):
# With indent # With indent
out_indent = StringIO() out_indent = StringIO()
call_command('export_channels_schema', indent=2, stdout=out_indent) call_command("export_channels_schema", indent=2, stdout=out_indent)
# Without indent (compact) # Without indent (compact)
out_compact = StringIO() out_compact = StringIO()
call_command('export_channels_schema', indent=0, stdout=out_compact) call_command("export_channels_schema", indent=0, stdout=out_compact)
# Indented should be longer (has whitespace) # Indented should be longer (has whitespace)
self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue())) self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
@@ -918,13 +927,14 @@ class WebSocketRPCTests(TestCase):
"""Tests for WebSocket RPC functionality.""" """Tests for WebSocket RPC functionality."""
def setUp(self): def setUp(self):
# Clear djarea registry # Clear mizan registry
from djarea.setup.registry import clear_registry from mizan_core.registry import clear_registry
clear_registry() clear_registry()
# Register test functions # Register test functions
from djarea.client import client from mizan.client import client
from djarea.setup.registry import register from mizan_core.registry import register
from pydantic import BaseModel from pydantic import BaseModel
class EchoOutput(BaseModel): class EchoOutput(BaseModel):
@@ -936,11 +946,13 @@ class WebSocketRPCTests(TestCase):
@client(websocket=True) @client(websocket=True)
def rpc_echo(request, message: str) -> EchoOutput: def rpc_echo(request, message: str) -> EchoOutput:
return EchoOutput(echo=f"Echo: {message}") return EchoOutput(echo=f"Echo: {message}")
register(rpc_echo, "rpc_echo") register(rpc_echo, "rpc_echo")
@client(websocket=True) @client(websocket=True)
def rpc_add(request, a: int, b: int) -> AddOutput: def rpc_add(request, a: int, b: int) -> AddOutput:
return AddOutput(result=a + b) return AddOutput(result=a + b)
register(rpc_add, "rpc_add") register(rpc_add, "rpc_add")
@client(websocket=True) @client(websocket=True)
@@ -948,16 +960,18 @@ class WebSocketRPCTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Authentication required") raise PermissionError("Authentication required")
return EchoOutput(echo=f"Hello, {request.user.email}") return EchoOutput(echo=f"Hello, {request.user.email}")
register(rpc_auth_required, "rpc_auth_required") register(rpc_auth_required, "rpc_auth_required")
def tearDown(self): def tearDown(self):
from djarea.setup.registry import clear_registry from mizan_core.registry import clear_registry
clear_registry() clear_registry()
def test_handle_rpc_success(self): def test_handle_rpc_success(self):
"""_handle_rpc should execute function and return result.""" """_handle_rpc should execute function and return result."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = { consumer.scope = {
@@ -971,11 +985,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "test-123", {
"fn": "rpc_echo", "id": "test-123",
"args": {"message": "Hello"}, "fn": "rpc_echo",
}) "args": {"message": "Hello"},
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -989,7 +1005,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_with_multiple_args(self): def test_handle_rpc_with_multiple_args(self):
"""_handle_rpc should handle functions with multiple arguments.""" """_handle_rpc should handle functions with multiple arguments."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1001,11 +1017,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "add-123", {
"fn": "rpc_add", "id": "add-123",
"args": {"a": 5, "b": 3}, "fn": "rpc_add",
}) "args": {"a": 5, "b": 3},
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1016,7 +1034,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_function_not_found(self): def test_handle_rpc_function_not_found(self):
"""_handle_rpc should return error for unknown function.""" """_handle_rpc should return error for unknown function."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1028,11 +1046,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "test-456", {
"fn": "nonexistent_function", "id": "test-456",
"args": {}, "fn": "nonexistent_function",
}) "args": {},
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1044,7 +1064,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_validation_error(self): def test_handle_rpc_validation_error(self):
"""_handle_rpc should return validation error for invalid input.""" """_handle_rpc should return validation error for invalid input."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1056,11 +1076,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "test-789", {
"fn": "rpc_echo", "id": "test-789",
"args": {}, # Missing required 'message' field "fn": "rpc_echo",
}) "args": {}, # Missing required 'message' field
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1072,7 +1094,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_missing_id(self): def test_handle_rpc_missing_id(self):
"""_handle_rpc should return error when id is missing.""" """_handle_rpc should return error when id is missing."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1084,11 +1106,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"fn": "rpc_echo", {
"args": {"message": "test"}, "fn": "rpc_echo",
# Missing 'id' "args": {"message": "test"},
}) # Missing 'id'
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1099,7 +1123,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_missing_fn(self): def test_handle_rpc_missing_fn(self):
"""_handle_rpc should return error when fn is missing.""" """_handle_rpc should return error when fn is missing."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1111,11 +1135,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "test-abc", {
"args": {"message": "test"}, "id": "test-abc",
# Missing 'fn' "args": {"message": "test"},
}) # Missing 'fn'
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1127,7 +1153,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_with_unauthenticated_user(self): def test_handle_rpc_with_unauthenticated_user(self):
"""_handle_rpc should handle permission errors correctly.""" """_handle_rpc should handle permission errors correctly."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockAnonymousUser()} consumer.scope = {"user": MockAnonymousUser()}
@@ -1139,11 +1165,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "auth-test", {
"fn": "rpc_auth_required", "id": "auth-test",
"args": {}, "fn": "rpc_auth_required",
}) "args": {},
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1154,7 +1182,7 @@ class WebSocketRPCTests(TestCase):
def test_websocket_request_adapter(self): def test_websocket_request_adapter(self):
"""WebSocketRequest should provide correct user and session.""" """WebSocketRequest should provide correct user and session."""
from djarea.channels.connection import WebSocketRequest from mizan.channels.connection import WebSocketRequest
mock_user = MockUser(email="ws@example.com") mock_user = MockUser(email="ws@example.com")
scope = { scope = {

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
""" """
Advanced Penetration Tests for Djarea Server Functions Advanced Penetration Tests for mizan Server Functions
These tests simulate a professional security researcher attempting to break These tests simulate a professional security researcher attempting to break
the protocol. Focus areas: the protocol. Focus areas:
@@ -36,14 +36,14 @@ from django.http import HttpRequest
from django.test import RequestFactory, TestCase, override_settings from django.test import RequestFactory, TestCase, override_settings
from pydantic import BaseModel, field_validator, model_validator from pydantic import BaseModel, field_validator, model_validator
from djarea.client.executor import ( from mizan.client.executor import (
ErrorCode, ErrorCode,
FunctionError, FunctionError,
FunctionResult, FunctionResult,
execute_function, execute_function,
) )
from djarea.setup.registry import clear_registry, get_function, register from mizan_core.registry import clear_registry, get_function, register
from djarea.client import ServerFunction, client from mizan.client import ServerFunction, client
# ============================================================================= # =============================================================================
@@ -86,16 +86,19 @@ class MemoryExhaustionTests(TestCase):
@client @client
def process_data(request: HttpRequest, data: dict) -> SimpleOutput: def process_data(request: HttpRequest, data: dict) -> SimpleOutput:
return SimpleOutput(value=str(len(str(data)))) return SimpleOutput(value=str(len(str(data))))
register(process_data, "process_data") register(process_data, "process_data")
@client @client
def process_string(request: HttpRequest, text: str) -> SimpleOutput: def process_string(request: HttpRequest, text: str) -> SimpleOutput:
return SimpleOutput(value=f"len={len(text)}") return SimpleOutput(value=f"len={len(text)}")
register(process_string, "process_string") register(process_string, "process_string")
@client @client
def process_list(request: HttpRequest, items: list) -> SimpleOutput: def process_list(request: HttpRequest, items: list) -> SimpleOutput:
return SimpleOutput(value=str(len(items))) return SimpleOutput(value=str(len(items)))
register(process_list, "process_list") register(process_list, "process_list")
def tearDown(self): def tearDown(self):
@@ -141,7 +144,9 @@ class MemoryExhaustionTests(TestCase):
def create_wide_nested(depth, width): def create_wide_nested(depth, width):
if depth == 0: if depth == 0:
return "leaf" return "leaf"
return {f"key_{i}": create_wide_nested(depth - 1, width) for i in range(width)} return {
f"key_{i}": create_wide_nested(depth - 1, width) for i in range(width)
}
# 5 levels deep, 10 wide = 10^5 = 100,000 nodes # 5 levels deep, 10 wide = 10^5 = 100,000 nodes
wide_structure = create_wide_nested(5, 10) wide_structure = create_wide_nested(5, 10)
@@ -225,16 +230,19 @@ class TypeConfusionTests(TestCase):
@client @client
def numeric_func(request: HttpRequest, value: float) -> NumericOutput: def numeric_func(request: HttpRequest, value: float) -> NumericOutput:
return NumericOutput(result=value * 2) return NumericOutput(result=value * 2)
register(numeric_func, "numeric_func") register(numeric_func, "numeric_func")
@client @client
def any_input(request: HttpRequest, data: Any) -> SimpleOutput: def any_input(request: HttpRequest, data: Any) -> SimpleOutput:
return SimpleOutput(value=str(type(data).__name__)) return SimpleOutput(value=str(type(data).__name__))
register(any_input, "any_input") register(any_input, "any_input")
@client @client
def bool_func(request: HttpRequest, flag: bool) -> SimpleOutput: def bool_func(request: HttpRequest, flag: bool) -> SimpleOutput:
return SimpleOutput(value="yes" if flag else "no") return SimpleOutput(value="yes" if flag else "no")
register(bool_func, "bool_func") register(bool_func, "bool_func")
def tearDown(self): def tearDown(self):
@@ -254,8 +262,9 @@ class TypeConfusionTests(TestCase):
request = self._make_request() request = self._make_request()
import math import math
# JSON doesn't support NaN directly, but we test the boundary # JSON doesn't support NaN directly, but we test the boundary
result = execute_function(request, "numeric_func", {"value": float('nan')}) result = execute_function(request, "numeric_func", {"value": float("nan")})
# numeric_func doubles the value; NaN * 2 is still NaN # numeric_func doubles the value; NaN * 2 is still NaN
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
@@ -267,21 +276,21 @@ class TypeConfusionTests(TestCase):
""" """
request = self._make_request() request = self._make_request()
result = execute_function(request, "numeric_func", {"value": float('inf')}) result = execute_function(request, "numeric_func", {"value": float("inf")})
# inf * 2 is still inf # inf * 2 is still inf
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
self.assertEqual(result.data["result"], float('inf')) self.assertEqual(result.data["result"], float("inf"))
def test_negative_infinity_handling(self): def test_negative_infinity_handling(self):
"""Test handling of negative infinity.""" """Test handling of negative infinity."""
request = self._make_request() request = self._make_request()
result = execute_function(request, "numeric_func", {"value": float('-inf')}) result = execute_function(request, "numeric_func", {"value": float("-inf")})
# -inf * 2 is still -inf # -inf * 2 is still -inf
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
self.assertEqual(result.data["result"], float('-inf')) self.assertEqual(result.data["result"], float("-inf"))
def test_very_small_float(self): def test_very_small_float(self):
"""Test handling of very small floats (denormalized).""" """Test handling of very small floats (denormalized)."""
@@ -304,7 +313,7 @@ class TypeConfusionTests(TestCase):
result = execute_function(request, "numeric_func", {"value": huge}) result = execute_function(request, "numeric_func", {"value": huge})
# Doubling max float should overflow to inf # Doubling max float should overflow to inf
if isinstance(result, FunctionResult): if isinstance(result, FunctionResult):
self.assertEqual(result.data["result"], float('inf')) self.assertEqual(result.data["result"], float("inf"))
def test_boolean_type_confusion(self): def test_boolean_type_confusion(self):
""" """
@@ -319,7 +328,7 @@ class TypeConfusionTests(TestCase):
(True, "yes"), (True, "yes"),
(False, "no"), (False, "no"),
(1, "yes"), # int 1 -> bool True (1, "yes"), # int 1 -> bool True
(0, "no"), # int 0 -> bool False (0, "no"), # int 0 -> bool False
("true", "yes"), # string coercion ("true", "yes"), # string coercion
("false", "no"), ("false", "no"),
] ]
@@ -401,12 +410,14 @@ class RaceConditionTests(TestCase):
test_instance.executions.append(exec_time) test_instance.executions.append(exec_time)
return TimingOutput(authenticated=is_auth, timestamp=exec_time) return TimingOutput(authenticated=is_auth, timestamp=exec_time)
register(timed_auth_func, "timed_auth_func") register(timed_auth_func, "timed_auth_func")
@client @client
def counter_func(request: HttpRequest) -> SimpleOutput: def counter_func(request: HttpRequest) -> SimpleOutput:
test_instance.call_count += 1 test_instance.call_count += 1
return SimpleOutput(value=str(test_instance.call_count)) return SimpleOutput(value=str(test_instance.call_count))
register(counter_func, "counter_func") register(counter_func, "counter_func")
def tearDown(self): def tearDown(self):
@@ -454,6 +465,7 @@ class RaceConditionTests(TestCase):
Simulates checking if the user authentication state could change Simulates checking if the user authentication state could change
between validation and execution. between validation and execution.
""" """
# Create a user mock that changes state # Create a user mock that changes state
class MutableUser: class MutableUser:
def __init__(self): def __init__(self):
@@ -510,6 +522,7 @@ class PydanticBypassTests(TestCase):
@client @client
def typed_func(request: HttpRequest, count: int, name: str) -> SimpleOutput: def typed_func(request: HttpRequest, count: int, name: str) -> SimpleOutput:
return SimpleOutput(value=f"{name}:{count}") return SimpleOutput(value=f"{name}:{count}")
register(typed_func, "typed_func") register(typed_func, "typed_func")
@client @client
@@ -518,6 +531,7 @@ class PydanticBypassTests(TestCase):
if "@" not in email: if "@" not in email:
raise ValueError("Invalid email format") raise ValueError("Invalid email format")
return SimpleOutput(value=email) return SimpleOutput(value=email)
register(strict_func, "strict_func") register(strict_func, "strict_func")
def tearDown(self): def tearDown(self):
@@ -537,7 +551,9 @@ class PydanticBypassTests(TestCase):
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
# Invalid type - dict for int # Invalid type - dict for int
result = execute_function(request, "typed_func", {"count": {"nested": 1}, "name": "test"}) result = execute_function(
request, "typed_func", {"count": {"nested": 1}, "name": "test"}
)
self.assertIsInstance(result, FunctionError) self.assertIsInstance(result, FunctionError)
self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR) self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)
@@ -606,13 +622,14 @@ class WebSocketProtocolTests(TestCase):
@client @client
def ws_func(request: HttpRequest, data: str) -> SimpleOutput: def ws_func(request: HttpRequest, data: str) -> SimpleOutput:
return SimpleOutput(value=data) return SimpleOutput(value=data)
register(ws_func, "ws_func") register(ws_func, "ws_func")
def tearDown(self): def tearDown(self):
clear_registry() clear_registry()
def _create_consumer(self, user=None): def _create_consumer(self, user=None):
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": user or AnonymousUser()} consumer.scope = {"user": user or AnonymousUser()}
@@ -680,7 +697,7 @@ class WebSocketProtocolTests(TestCase):
"action": "rpc", "action": "rpc",
"id": mal_id, "id": mal_id,
"fn": "ws_func", "fn": "ws_func",
"args": {"data": "test"} "args": {"data": "test"},
} }
async_to_sync(consumer.receive_json)(payload) async_to_sync(consumer.receive_json)(payload)
@@ -693,8 +710,8 @@ class WebSocketProtocolTests(TestCase):
Try rapid subscribe/unsubscribe cycles and malformed params. Try rapid subscribe/unsubscribe cycles and malformed params.
""" """
from djarea.channels import register as register_channel, ReactChannel from mizan.channels import register as register_channel, ReactChannel
from djarea.channels import _registry as channels_registry from mizan.channels import _registry as channels_registry
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
channels_registry.clear() channels_registry.clear()
@@ -718,14 +735,12 @@ class WebSocketProtocolTests(TestCase):
# Rapid subscribe/unsubscribe # Rapid subscribe/unsubscribe
for i in range(50): for i in range(50):
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "test-channel", {"channel": "test-channel", "params": {"room": f"room_{i}"}}
"params": {"room": f"room_{i}"} )
}) async_to_sync(consumer._handle_unsubscribe)(
async_to_sync(consumer._handle_unsubscribe)({ {"channel": "test-channel", "params": {"room": f"room_{i}"}}
"channel": "test-channel", )
"params": {"room": f"room_{i}"}
})
# Should not have any lingering subscriptions # Should not have any lingering subscriptions
self.assertEqual(len(consumer._subscriptions), 0) self.assertEqual(len(consumer._subscriptions), 0)
@@ -736,8 +751,8 @@ class WebSocketProtocolTests(TestCase):
""" """
Test attempting to subscribe to the same channel twice. Test attempting to subscribe to the same channel twice.
""" """
from djarea.channels import register as register_channel, ReactChannel from mizan.channels import register as register_channel, ReactChannel
from djarea.channels import _registry as channels_registry from mizan.channels import _registry as channels_registry
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
channels_registry.clear() channels_registry.clear()
@@ -757,17 +772,15 @@ class WebSocketProtocolTests(TestCase):
consumer, messages = self._create_consumer() consumer, messages = self._create_consumer()
# First subscription # First subscription
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "dup-channel", {"channel": "dup-channel", "params": {}}
"params": {} )
})
self.assertIn("subscribed", messages[-1]) self.assertIn("subscribed", messages[-1])
# Second subscription to same channel # Second subscription to same channel
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "dup-channel", {"channel": "dup-channel", "params": {}}
"params": {} )
})
# Should return error about already subscribed # Should return error about already subscribed
self.assertIn("error", messages[-1]) self.assertIn("error", messages[-1])
@@ -797,6 +810,7 @@ class TimingSideChannelTests(TestCase):
@client @client
def existing_func(request: HttpRequest) -> SimpleOutput: def existing_func(request: HttpRequest) -> SimpleOutput:
return SimpleOutput(value="exists") return SimpleOutput(value="exists")
register(existing_func, "existing_func") register(existing_func, "existing_func")
@client @client
@@ -804,6 +818,7 @@ class TimingSideChannelTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Auth required") raise PermissionError("Auth required")
return SimpleOutput(value="authenticated") return SimpleOutput(value="authenticated")
register(auth_func, "auth_func") register(auth_func, "auth_func")
def tearDown(self): def tearDown(self):
@@ -910,6 +925,7 @@ class UnicodeNormalizationTests(TestCase):
if username == "admin": if username == "admin":
raise PermissionError("Reserved username") raise PermissionError("Reserved username")
return SimpleOutput(value=f"Hello, {username}") return SimpleOutput(value=f"Hello, {username}")
register(username_func, "username_func") register(username_func, "username_func")
def tearDown(self): def tearDown(self):
@@ -1011,6 +1027,7 @@ class JSONParsingEdgeCaseTests(TestCase):
@client @client
def json_func(request: HttpRequest, data: dict) -> SimpleOutput: def json_func(request: HttpRequest, data: dict) -> SimpleOutput:
return SimpleOutput(value=json.dumps(data)) return SimpleOutput(value=json.dumps(data))
register(json_func, "json_func") register(json_func, "json_func")
def tearDown(self): def tearDown(self):
@@ -1063,7 +1080,7 @@ class JSONParsingEdgeCaseTests(TestCase):
edge_numbers = { edge_numbers = {
"max_safe_int": 9007199254740991, # 2^53 - 1 "max_safe_int": 9007199254740991, # 2^53 - 1
"beyond_safe": 9007199254740993, # 2^53 + 1 (loses precision in JS) "beyond_safe": 9007199254740993, # 2^53 + 1 (loses precision in JS)
"huge_int": 10**100, "huge_int": 10**100,
"tiny_float": 1e-308, "tiny_float": 1e-308,
"huge_float": 1e308, "huge_float": 1e308,
@@ -1097,6 +1114,7 @@ class AuthorizationBoundaryTests(TestCase):
if target_role not in allowed_roles: if target_role not in allowed_roles:
raise PermissionError(f"Cannot escalate to {target_role}") raise PermissionError(f"Cannot escalate to {target_role}")
return SimpleOutput(value=f"Role set to {target_role}") return SimpleOutput(value=f"Role set to {target_role}")
register(escalation_func, "escalation_func") register(escalation_func, "escalation_func")
def tearDown(self): def tearDown(self):
@@ -1160,8 +1178,8 @@ class RegistrationSecurityTests(TestCase):
Note: Re-registration of the same function name IS allowed for hot reload. Note: Re-registration of the same function name IS allowed for hot reload.
But a DIFFERENT function cannot take over an existing name. But a DIFFERENT function cannot take over an existing name.
""" """
from djarea.client import ServerFunction from mizan.client import ServerFunction
from djarea.setup.registry import register from mizan_core.registry import register
# Register first function # Register first function
class OriginalFunc(ServerFunction): class OriginalFunc(ServerFunction):
@@ -1196,6 +1214,7 @@ class RegistrationSecurityTests(TestCase):
@client @client
def normal_func_name(request: HttpRequest) -> SimpleOutput: def normal_func_name(request: HttpRequest) -> SimpleOutput:
return SimpleOutput(value="ok") return SimpleOutput(value="ok")
register(normal_func_name, "normal_func_name") register(normal_func_name, "normal_func_name")
fn = get_function("normal_func_name") fn = get_function("normal_func_name")

View File

@@ -1,5 +1,5 @@
""" """
Security-focused E2E tests for Djarea server functions. Security-focused E2E tests for mizan server functions.
These tests probe for potential vulnerabilities without running any These tests probe for potential vulnerabilities without running any
malicious code - they simply verify that defenses work correctly. malicious code - they simply verify that defenses work correctly.
@@ -22,16 +22,16 @@ from django.http import HttpRequest
from django.test import RequestFactory, TestCase, Client, override_settings from django.test import RequestFactory, TestCase, Client, override_settings
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from djarea.client.executor import ( from mizan.client.executor import (
ErrorCode, ErrorCode,
FunctionError, FunctionError,
FunctionResult, FunctionResult,
execute_function, execute_function,
function_call_view, function_call_view,
) )
from djarea.setup.registry import clear_registry, register, register_as, get_function from mizan_core.registry import clear_registry, register, register_as, get_function
from djarea.client import ServerFunction, client from mizan.client import ServerFunction, client
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
User = get_user_model() User = get_user_model()
@@ -90,6 +90,7 @@ class InputValidationSecurityTests(TestCase):
@client @client
def echo_any(request: HttpRequest, message: str) -> SimpleOutput: def echo_any(request: HttpRequest, message: str) -> SimpleOutput:
return SimpleOutput(value=message) return SimpleOutput(value=message)
register(echo_any, "echo_any") register(echo_any, "echo_any")
@client @client
@@ -97,16 +98,18 @@ class InputValidationSecurityTests(TestCase):
def count_depth(obj, depth=0): def count_depth(obj, depth=0):
if isinstance(obj, dict): if isinstance(obj, dict):
return max( return max(
(count_depth(v, depth + 1) for v in obj.values()), (count_depth(v, depth + 1) for v in obj.values()), default=depth
default=depth
) )
return depth return depth
return DeeplyNestedOutput(depth=count_depth(data)) return DeeplyNestedOutput(depth=count_depth(data))
register(process_nested, "process_nested") register(process_nested, "process_nested")
@client @client
def typed_input(request: HttpRequest, age: int, name: str) -> SimpleOutput: def typed_input(request: HttpRequest, age: int, name: str) -> SimpleOutput:
return SimpleOutput(value=f"{name}:{age}") return SimpleOutput(value=f"{name}:{age}")
register(typed_input, "typed_input") register(typed_input, "typed_input")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -184,8 +187,7 @@ class InputValidationSecurityTests(TestCase):
# Try to bypass integer validation with string # Try to bypass integer validation with string
result = execute_function( result = execute_function(
request, "typed_input", request, "typed_input", {"age": "25; DROP TABLE users", "name": "test"}
{"age": "25; DROP TABLE users", "name": "test"}
) )
# Pydantic should coerce "25; DROP TABLE users" and fail # Pydantic should coerce "25; DROP TABLE users" and fail
# because it's not a valid integer # because it's not a valid integer
@@ -208,8 +210,9 @@ class InputValidationSecurityTests(TestCase):
request = self._make_request() request = self._make_request()
result = execute_function( result = execute_function(
request, "echo_any", request,
{"message": "test", "__proto__": "polluted", "extra": "ignored"} "echo_any",
{"message": "test", "__proto__": "polluted", "extra": "ignored"},
) )
# Should succeed, extra fields ignored # Should succeed, extra fields ignored
@@ -246,6 +249,7 @@ class AuthorizationSecurityTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Authentication required") raise PermissionError("Authentication required")
return SensitiveOutput(secret="sensitive", user_id=request.user.id) return SensitiveOutput(secret="sensitive", user_id=request.user.id)
register(requires_auth, "requires_auth") register(requires_auth, "requires_auth")
@client @client
@@ -255,6 +259,7 @@ class AuthorizationSecurityTests(TestCase):
if not request.user.is_staff: if not request.user.is_staff:
raise PermissionError("Admin access required") raise PermissionError("Admin access required")
return AdminOnlyOutput(admin_data="secret admin data") return AdminOnlyOutput(admin_data="secret admin data")
register(requires_admin, "requires_admin") register(requires_admin, "requires_admin")
@client @client
@@ -264,6 +269,7 @@ class AuthorizationSecurityTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("User not logged in") raise PermissionError("User not logged in")
return SimpleOutput(value="ok") return SimpleOutput(value="ok")
register(leaky_auth_check, "leaky_auth_check") register(leaky_auth_check, "leaky_auth_check")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -316,6 +322,7 @@ class AuthorizationSecurityTests(TestCase):
def test_spoofed_is_authenticated_attribute(self): def test_spoofed_is_authenticated_attribute(self):
"""Test that spoofing is_authenticated doesn't work.""" """Test that spoofing is_authenticated doesn't work."""
# Create object that claims to be authenticated but isn't a real user # Create object that claims to be authenticated but isn't a real user
class FakeUser: class FakeUser:
is_authenticated = True is_authenticated = True
@@ -330,6 +337,7 @@ class AuthorizationSecurityTests(TestCase):
def test_user_id_manipulation_blocked(self): def test_user_id_manipulation_blocked(self):
"""Test that user can't access other users' data via input.""" """Test that user can't access other users' data via input."""
@client @client
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput: def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
# Properly checking: can only access own data # Properly checking: can only access own data
@@ -338,6 +346,7 @@ class AuthorizationSecurityTests(TestCase):
if request.user.id != target_user_id: if request.user.id != target_user_id:
raise PermissionError("Cannot access other users' data") raise PermissionError("Cannot access other users' data")
return SensitiveOutput(secret="data", user_id=target_user_id) return SensitiveOutput(secret="data", user_id=target_user_id)
register(get_user_data, "get_user_data") register(get_user_data, "get_user_data")
user = MagicMock() user = MagicMock()
@@ -380,11 +389,12 @@ class HTTPEndpointSecurityTests(TestCase):
@client @client
def public_echo(request: HttpRequest, message: str) -> SimpleOutput: def public_echo(request: HttpRequest, message: str) -> SimpleOutput:
return SimpleOutput(value=message) return SimpleOutput(value=message)
register(public_echo, "public_echo") register(public_echo, "public_echo")
def test_get_method_rejected(self): def test_get_method_rejected(self):
"""Test that GET requests are rejected.""" """Test that GET requests are rejected."""
request = self.factory.get("/api/djarea/call/") request = self.factory.get("/api/mizan/call/")
request.user = AnonymousUser() request.user = AnonymousUser()
response = function_call_view(request) response = function_call_view(request)
@@ -396,7 +406,7 @@ class HTTPEndpointSecurityTests(TestCase):
def test_put_method_rejected(self): def test_put_method_rejected(self):
"""Test that PUT requests are rejected.""" """Test that PUT requests are rejected."""
request = self.factory.put("/api/djarea/call/") request = self.factory.put("/api/mizan/call/")
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check
@@ -406,7 +416,7 @@ class HTTPEndpointSecurityTests(TestCase):
def test_delete_method_rejected(self): def test_delete_method_rejected(self):
"""Test that DELETE requests are rejected.""" """Test that DELETE requests are rejected."""
request = self.factory.delete("/api/djarea/call/") request = self.factory.delete("/api/mizan/call/")
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check
@@ -417,9 +427,7 @@ class HTTPEndpointSecurityTests(TestCase):
def test_invalid_json_rejected(self): def test_invalid_json_rejected(self):
"""Test that invalid JSON is rejected gracefully.""" """Test that invalid JSON is rejected gracefully."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/", data="{invalid json", content_type="application/json"
data="{invalid json",
content_type="application/json"
) )
request.user = AnonymousUser() request.user = AnonymousUser()
# Bypass CSRF for this test # Bypass CSRF for this test
@@ -435,9 +443,7 @@ class HTTPEndpointSecurityTests(TestCase):
def test_empty_body_rejected(self): def test_empty_body_rejected(self):
"""Test that empty body is rejected (fn field required).""" """Test that empty body is rejected (fn field required)."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/", data="", content_type="application/json"
data="",
content_type="application/json"
) )
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
@@ -450,9 +456,9 @@ class HTTPEndpointSecurityTests(TestCase):
def test_missing_fn_field_rejected(self): def test_missing_fn_field_rejected(self):
"""Test that request without fn field is rejected.""" """Test that request without fn field is rejected."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data='{"args": {"message": "test"}}', data='{"args": {"message": "test"}}',
content_type="application/json" content_type="application/json",
) )
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
@@ -467,9 +473,9 @@ class HTTPEndpointSecurityTests(TestCase):
def test_content_type_not_enforced(self): def test_content_type_not_enforced(self):
"""Test behavior with wrong content type.""" """Test behavior with wrong content type."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data='{"fn": "public_echo", "args": {"message": "test"}}', data='{"fn": "public_echo", "args": {"message": "test"}}',
content_type="text/plain" content_type="text/plain",
) )
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
@@ -491,9 +497,9 @@ class HTTPEndpointSecurityTests(TestCase):
for name in malicious_names: for name in malicious_names:
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data=json.dumps({"fn": name, "args": {}}), data=json.dumps({"fn": name, "args": {}}),
content_type="application/json" content_type="application/json",
) )
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
@@ -528,6 +534,7 @@ class WebSocketRPCSecurityTests(TestCase):
@client(websocket=True) @client(websocket=True)
def ws_echo(request: HttpRequest, message: str) -> SimpleOutput: def ws_echo(request: HttpRequest, message: str) -> SimpleOutput:
return SimpleOutput(value=message) return SimpleOutput(value=message)
register(ws_echo, "ws_echo") register(ws_echo, "ws_echo")
@client(websocket=True) @client(websocket=True)
@@ -535,11 +542,12 @@ class WebSocketRPCSecurityTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Auth required") raise PermissionError("Auth required")
return SensitiveOutput(secret="data", user_id=request.user.id) return SensitiveOutput(secret="data", user_id=request.user.id)
register(ws_auth_required, "ws_auth_required") register(ws_auth_required, "ws_auth_required")
def test_rpc_without_id_field(self): def test_rpc_without_id_field(self):
"""Test RPC call without required id field.""" """Test RPC call without required id field."""
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -552,7 +560,9 @@ class WebSocketRPCSecurityTests(TestCase):
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Call without id # Call without id
async_to_sync(consumer._handle_rpc)({"fn": "ws_echo", "args": {"message": "test"}}) async_to_sync(consumer._handle_rpc)(
{"fn": "ws_echo", "args": {"message": "test"}}
)
# Should return error about missing id # Should return error about missing id
self.assertEqual(len(sent_messages), 1) self.assertEqual(len(sent_messages), 1)
@@ -560,7 +570,7 @@ class WebSocketRPCSecurityTests(TestCase):
def test_rpc_without_fn_field(self): def test_rpc_without_fn_field(self):
"""Test RPC call without function name.""" """Test RPC call without function name."""
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -581,7 +591,7 @@ class WebSocketRPCSecurityTests(TestCase):
def test_rpc_nonexistent_function(self): def test_rpc_nonexistent_function(self):
"""Test RPC call to non-existent function.""" """Test RPC call to non-existent function."""
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -592,18 +602,16 @@ class WebSocketRPCSecurityTests(TestCase):
sent_messages = [] sent_messages = []
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
async_to_sync(consumer._handle_rpc)({ async_to_sync(consumer._handle_rpc)(
"id": "123", {"id": "123", "fn": "nonexistent_function", "args": {}}
"fn": "nonexistent_function", )
"args": {}
})
self.assertEqual(sent_messages[0]["ok"], False) self.assertEqual(sent_messages[0]["ok"], False)
self.assertEqual(sent_messages[0]["error"]["code"], "NOT_FOUND") self.assertEqual(sent_messages[0]["error"]["code"], "NOT_FOUND")
def test_rpc_validation_error_returned(self): def test_rpc_validation_error_returned(self):
"""Test that validation errors are returned properly over RPC.""" """Test that validation errors are returned properly over RPC."""
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -615,20 +623,20 @@ class WebSocketRPCSecurityTests(TestCase):
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Call with wrong input type # Call with wrong input type
async_to_sync(consumer._handle_rpc)({ async_to_sync(consumer._handle_rpc)(
"id": "123", {
"fn": "ws_echo", "id": "123",
"args": {"message": 12345} # Should be string "fn": "ws_echo",
}) "args": {"message": 12345}, # Should be string
}
)
# Pydantic coerces int to string, so this actually succeeds # Pydantic coerces int to string, so this actually succeeds
# Let's test with missing required field instead # Let's test with missing required field instead
sent_messages.clear() sent_messages.clear()
async_to_sync(consumer._handle_rpc)({ async_to_sync(consumer._handle_rpc)(
"id": "124", {"id": "124", "fn": "ws_echo", "args": {}} # Missing message
"fn": "ws_echo", )
"args": {} # Missing message
})
self.assertEqual(sent_messages[0]["ok"], False) self.assertEqual(sent_messages[0]["ok"], False)
self.assertEqual(sent_messages[0]["error"]["code"], "VALIDATION_ERROR") self.assertEqual(sent_messages[0]["error"]["code"], "VALIDATION_ERROR")
@@ -662,11 +670,13 @@ class InformationDisclosureTests(TestCase):
# Simulate accessing sensitive config that might leak in error # Simulate accessing sensitive config that might leak in error
secret_key = "super_secret_key_12345" secret_key = "super_secret_key_12345"
raise RuntimeError(f"Database error with key: {secret_key}") raise RuntimeError(f"Database error with key: {secret_key}")
register(error_with_sensitive_data, "error_with_sensitive_data") register(error_with_sensitive_data, "error_with_sensitive_data")
@client @client
def working_function(request: HttpRequest) -> SimpleOutput: def working_function(request: HttpRequest) -> SimpleOutput:
return SimpleOutput(value="works") return SimpleOutput(value="works")
register(working_function, "working_function") register(working_function, "working_function")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -712,9 +722,11 @@ class InformationDisclosureTests(TestCase):
def test_validation_errors_dont_leak_internals(self): def test_validation_errors_dont_leak_internals(self):
"""Test that validation errors only show field-level info.""" """Test that validation errors only show field-level info."""
@client @client
def validated_func(request: HttpRequest, secret_field: str) -> SimpleOutput: def validated_func(request: HttpRequest, secret_field: str) -> SimpleOutput:
return SimpleOutput(value=secret_field) return SimpleOutput(value=secret_field)
register(validated_func, "validated_func") register(validated_func, "validated_func")
request = self._make_request() request = self._make_request()
@@ -758,11 +770,13 @@ class InjectionPreventionTests(TestCase):
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput: def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
# This function just echoes - the test is about validation # This function just echoes - the test is about validation
return SimpleOutput(value=user_input) return SimpleOutput(value=user_input)
register(echo_safe, "echo_safe") register(echo_safe, "echo_safe")
@client @client
def process_dict(request: HttpRequest, data: dict) -> SimpleOutput: def process_dict(request: HttpRequest, data: dict) -> SimpleOutput:
return SimpleOutput(value=str(len(data))) return SimpleOutput(value=str(len(data)))
register(process_dict, "process_dict") register(process_dict, "process_dict")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -847,8 +861,7 @@ class InjectionPreventionTests(TestCase):
request = self._make_request() request = self._make_request()
result = execute_function( result = execute_function(
request, "process_dict", request, "process_dict", {"data": {"__proto__": {"admin": True}}}
{"data": {"__proto__": {"admin": True}}}
) )
# Should succeed - it's just a dict with a key named "__proto__" # Should succeed - it's just a dict with a key named "__proto__"
@@ -870,18 +883,20 @@ class ChannelAuthorizationTests(TestCase):
def setUp(self): def setUp(self):
clear_registry() clear_registry()
# Also clear the channels registry # Also clear the channels registry
from djarea.channels import _registry as channels_registry from mizan.channels import _registry as channels_registry
channels_registry.clear() channels_registry.clear()
self._register_test_channels() self._register_test_channels()
def tearDown(self): def tearDown(self):
clear_registry() clear_registry()
from djarea.channels import _registry as channels_registry from mizan.channels import _registry as channels_registry
channels_registry.clear() channels_registry.clear()
def _register_test_channels(self): def _register_test_channels(self):
"""Register test channels using the channels module's register.""" """Register test channels using the channels module's register."""
from djarea.channels import register as register_channel, ReactChannel from mizan.channels import register as register_channel, ReactChannel
class PublicChannel(ReactChannel): class PublicChannel(ReactChannel):
class DjangoMessage(BaseModel): class DjangoMessage(BaseModel):
@@ -923,8 +938,8 @@ class ChannelAuthorizationTests(TestCase):
def test_authorize_exception_handling(self): def test_authorize_exception_handling(self):
"""Test that exceptions in authorize() are handled safely.""" """Test that exceptions in authorize() are handled safely."""
from djarea.channels import register as register_channel, ReactChannel from mizan.channels import register as register_channel, ReactChannel
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
class ErrorChannel(ReactChannel): class ErrorChannel(ReactChannel):
@@ -947,10 +962,9 @@ class ChannelAuthorizationTests(TestCase):
sent_messages = [] sent_messages = []
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "error-channel", {"channel": "error-channel", "params": {}}
"params": {} )
})
# Should return error, not crash # Should return error, not crash
self.assertEqual(len(sent_messages), 1) self.assertEqual(len(sent_messages), 1)
@@ -958,7 +972,7 @@ class ChannelAuthorizationTests(TestCase):
def test_authorize_false_blocks_subscription(self): def test_authorize_false_blocks_subscription(self):
"""Test that returning False from authorize blocks subscription.""" """Test that returning False from authorize blocks subscription."""
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -969,10 +983,9 @@ class ChannelAuthorizationTests(TestCase):
sent_messages = [] sent_messages = []
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "auth-channel", {"channel": "auth-channel", "params": {}}
"params": {} )
})
# Should be rejected # Should be rejected
self.assertIn("error", sent_messages[0]) self.assertIn("error", sent_messages[0])
@@ -980,7 +993,7 @@ class ChannelAuthorizationTests(TestCase):
def test_param_validation_before_authorize(self): def test_param_validation_before_authorize(self):
"""Test that params are validated before authorize is called.""" """Test that params are validated before authorize is called."""
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -992,17 +1005,16 @@ class ChannelAuthorizationTests(TestCase):
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Invalid params (string instead of int) # Invalid params (string instead of int)
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "room-channel", {"channel": "room-channel", "params": {"room_id": "not_an_int"}}
"params": {"room_id": "not_an_int"} )
})
# Should fail validation # Should fail validation
self.assertIn("error", sent_messages[0]) self.assertIn("error", sent_messages[0])
def test_room_authorization_enforced(self): def test_room_authorization_enforced(self):
"""Test that room-level authorization is enforced.""" """Test that room-level authorization is enforced."""
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -1015,17 +1027,15 @@ class ChannelAuthorizationTests(TestCase):
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Room 1 - allowed # Room 1 - allowed
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "room-channel", {"channel": "room-channel", "params": {"room_id": 1}}
"params": {"room_id": 1} )
})
self.assertIn("subscribed", sent_messages[-1]) self.assertIn("subscribed", sent_messages[-1])
# Room 999 - not allowed # Room 999 - not allowed
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "room-channel", {"channel": "room-channel", "params": {"room_id": 999}}
"params": {"room_id": 999} )
})
self.assertIn("error", sent_messages[-1]) self.assertIn("error", sent_messages[-1])
@@ -1057,6 +1067,7 @@ class AbusePreventionTests(TestCase):
@client @client
def simple_func(request: HttpRequest) -> SimpleOutput: def simple_func(request: HttpRequest) -> SimpleOutput:
return SimpleOutput(value="ok") return SimpleOutput(value="ok")
register(simple_func, "simple_func") register(simple_func, "simple_func")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -1081,9 +1092,11 @@ class AbusePreventionTests(TestCase):
def test_large_batch_execution(self): def test_large_batch_execution(self):
"""Test handling of large batch of different inputs.""" """Test handling of large batch of different inputs."""
@client @client
def batch_func(request: HttpRequest, idx: int) -> SimpleOutput: def batch_func(request: HttpRequest, idx: int) -> SimpleOutput:
return SimpleOutput(value=f"item_{idx}") return SimpleOutput(value=f"item_{idx}")
register(batch_func, "batch_func") register(batch_func, "batch_func")
request = self._make_request() request = self._make_request()

View File

@@ -1,5 +1,5 @@
""" """
Stress tests for djarea.shapes edge cases and deep nesting. Stress tests for mizan.shapes edge cases and deep nesting.
Models: Publisher Author Book Chapter Section (5 levels deep), Models: Publisher Author Book Chapter Section (5 levels deep),
two FKs to same model, slug PK, UUID PK, self-referential FK, M2M, two FKs to same model, slug PK, UUID PK, self-referential FK, M2M,
@@ -11,12 +11,18 @@ from typing import get_type_hints
from django.test import TestCase from django.test import TestCase
from djarea.shapes import Shape, Diff, NestedDiff from mizan.shapes import Shape, Diff, NestedDiff
import uuid import uuid
from tests.models import ( from tests.models import (
Publisher, Author, Book, Chapter, Section, Tag, Category, Publisher,
Author,
Book,
Chapter,
Section,
Tag,
Category,
) )
@@ -99,6 +105,7 @@ class PublisherDetailShape(Shape[Publisher]):
class BookWithEditorShape(Shape[Book]): class BookWithEditorShape(Shape[Book]):
"""Two FKs to the same model (author + editor).""" """Two FKs to the same model (author + editor)."""
id: int | None = None id: int | None = None
title: str title: str
author: FlatAuthorShape author: FlatAuthorShape
@@ -117,7 +124,6 @@ class CategoryShape(Shape[Category]):
class TestShapeClassCreation(TestCase): class TestShapeClassCreation(TestCase):
def test_flat_shape_has_no_nested(self): def test_flat_shape_has_no_nested(self):
self.assertEqual(FlatAuthorShape._nested, {}) self.assertEqual(FlatAuthorShape._nested, {})
self.assertEqual(FlatAuthorShape._field_names, ["id", "name"]) self.assertEqual(FlatAuthorShape._field_names, ["id", "name"])
@@ -171,7 +177,9 @@ class TestShapeClassCreation(TestCase):
self.assertIs(CategoryShape._nested["children"], CategoryShape) self.assertIs(CategoryShape._nested["children"], CategoryShape)
def test_multiple_shapes_same_model_independent(self): def test_multiple_shapes_same_model_independent(self):
self.assertLess(len(FlatBookShape._field_names), len(BookDetailShape._field_names)) self.assertLess(
len(FlatBookShape._field_names), len(BookDetailShape._field_names)
)
self.assertNotEqual(FlatBookShape._spec, BookDetailShape._spec) self.assertNotEqual(FlatBookShape._spec, BookDetailShape._spec)
@@ -181,7 +189,6 @@ class TestShapeClassCreation(TestCase):
class TestShapeQuery(TestCase): class TestShapeQuery(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.publisher = Publisher.objects.create(name="Orbit", country="UK") cls.publisher = Publisher.objects.create(name="Orbit", country="UK")
@@ -189,8 +196,10 @@ class TestShapeQuery(TestCase):
name="Ursula", bio="Legend", publisher=cls.publisher name="Ursula", bio="Legend", publisher=cls.publisher
) )
cls.author = Author.objects.create( cls.author = Author.objects.create(
name="Ann Leckie", bio="Imperial Radch", name="Ann Leckie",
publisher=cls.publisher, mentor=cls.mentor, bio="Imperial Radch",
publisher=cls.publisher,
mentor=cls.mentor,
) )
cls.editor = Author.objects.create( cls.editor = Author.objects.create(
name="Devi Pillai", bio="Editor", publisher=cls.publisher name="Devi Pillai", bio="Editor", publisher=cls.publisher
@@ -199,9 +208,12 @@ class TestShapeQuery(TestCase):
cls.tag_space = Tag.objects.create(slug="space-opera", label="Space Opera") cls.tag_space = Tag.objects.create(slug="space-opera", label="Space Opera")
cls.book = Book.objects.create( cls.book = Book.objects.create(
title="Ancillary Justice", isbn="9780316246620", title="Ancillary Justice",
page_count=386, is_published=True, isbn="9780316246620",
author=cls.author, editor=cls.editor, page_count=386,
is_published=True,
author=cls.author,
editor=cls.editor,
) )
cls.book.tags.add(cls.tag_sf, cls.tag_space) cls.book.tags.add(cls.tag_sf, cls.tag_space)
@@ -211,8 +223,12 @@ class TestShapeQuery(TestCase):
cls.ch2 = Chapter.objects.create( cls.ch2 = Chapter.objects.create(
book=cls.book, number=2, title="The Ship", word_count=4800 book=cls.book, number=2, title="The Ship", word_count=4800
) )
Section.objects.create(chapter=cls.ch1, heading="Opening", body="...", position=0) Section.objects.create(
Section.objects.create(chapter=cls.ch1, heading="Discovery", body="...", position=1) chapter=cls.ch1, heading="Opening", body="...", position=0
)
Section.objects.create(
chapter=cls.ch1, heading="Discovery", body="...", position=1
)
cls.root_cat = Category.objects.create(name="Fiction") cls.root_cat = Category.objects.create(name="Fiction")
cls.child_cat = Category.objects.create(name="Sci-Fi", parent=cls.root_cat) cls.child_cat = Category.objects.create(name="Sci-Fi", parent=cls.root_cat)
@@ -279,9 +295,12 @@ class TestShapeQuery(TestCase):
def test_nullable_fk_returns_none(self): def test_nullable_fk_returns_none(self):
book_no_editor = Book.objects.create( book_no_editor = Book.objects.create(
title="Provenance", isbn="9780316246699", title="Provenance",
page_count=448, is_published=True, isbn="9780316246699",
author=self.author, editor=None, page_count=448,
is_published=True,
author=self.author,
editor=None,
) )
results = BookWithEditorShape.query(lambda qs: qs.filter(pk=book_no_editor.pk)) results = BookWithEditorShape.query(lambda qs: qs.filter(pk=book_no_editor.pk))
self.assertEqual(len(results), 1) self.assertEqual(len(results), 1)
@@ -330,7 +349,6 @@ class TestShapeQuery(TestCase):
class TestDiff(TestCase): class TestDiff(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.publisher = Publisher.objects.create(name="Tor", country="US") cls.publisher = Publisher.objects.create(name="Tor", country="US")
@@ -338,8 +356,11 @@ class TestDiff(TestCase):
name="Brandon Sanderson", bio="Cosmere", publisher=cls.publisher name="Brandon Sanderson", bio="Cosmere", publisher=cls.publisher
) )
cls.book = Book.objects.create( cls.book = Book.objects.create(
title="Mistborn", isbn="9780765311788", title="Mistborn",
page_count=541, is_published=True, author=cls.author, isbn="9780765311788",
page_count=541,
is_published=True,
author=cls.author,
) )
cls.ch1 = Chapter.objects.create( cls.ch1 = Chapter.objects.create(
book=cls.book, number=1, title="Ash", word_count=6000 book=cls.book, number=1, title="Ash", word_count=6000
@@ -352,8 +373,11 @@ class TestDiff(TestCase):
def test_diff_no_changes(self): def test_diff_no_changes(self):
shape = BookCardShape( shape = BookCardShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
) )
d = shape.diff() d = shape.diff()
@@ -362,8 +386,11 @@ class TestDiff(TestCase):
def test_diff_detects_field_change(self): def test_diff_detects_field_change(self):
shape = BookCardShape( shape = BookCardShape(
id=self.book.pk, title="Mistborn: The Final Empire", id=self.book.pk,
isbn="9780765311788", page_count=541, is_published=True, title="Mistborn: The Final Empire",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
) )
d = shape.diff() d = shape.diff()
@@ -372,8 +399,11 @@ class TestDiff(TestCase):
def test_diff_multiple_field_changes(self): def test_diff_multiple_field_changes(self):
shape = BookCardShape( shape = BookCardShape(
id=self.book.pk, title="Mistborn: TFE", id=self.book.pk,
isbn="9780765311788", page_count=600, is_published=True, title="Mistborn: TFE",
isbn="9780765311788",
page_count=600,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
) )
d = shape.diff() d = shape.diff()
@@ -396,12 +426,23 @@ class TestDiff(TestCase):
def test_nested_diff_detects_updated_chapter(self): def test_nested_diff_detects_updated_chapter(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[ chapters=[
ChapterShape(id=self.ch1.pk, number=1, title="Ash Falls", word_count=6000, sections=[]), ChapterShape(
ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]), id=self.ch1.pk,
number=1,
title="Ash Falls",
word_count=6000,
sections=[],
),
ChapterShape(
id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]
),
], ],
tags=[], tags=[],
) )
@@ -411,13 +452,22 @@ class TestDiff(TestCase):
def test_nested_diff_detects_created(self): def test_nested_diff_detects_created(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[ chapters=[
ChapterShape(id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]), ChapterShape(
ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]), id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]
ChapterShape(id=None, number=3, title="New Chapter", word_count=0, sections=[]), ),
ChapterShape(
id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]
),
ChapterShape(
id=None, number=3, title="New Chapter", word_count=0, sections=[]
),
], ],
tags=[], tags=[],
) )
@@ -426,11 +476,16 @@ class TestDiff(TestCase):
def test_nested_diff_detects_deleted(self): def test_nested_diff_detects_deleted(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[ chapters=[
ChapterShape(id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]), ChapterShape(
id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]
),
], ],
tags=[], tags=[],
) )
@@ -439,12 +494,23 @@ class TestDiff(TestCase):
def test_nested_diff_combined_operations(self): def test_nested_diff_combined_operations(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[ chapters=[
ChapterShape(id=self.ch1.pk, number=1, title="Ash Rewritten", word_count=7000, sections=[]), ChapterShape(
ChapterShape(id=None, number=3, title="Epilogue", word_count=2000, sections=[]), id=self.ch1.pk,
number=1,
title="Ash Rewritten",
word_count=7000,
sections=[],
),
ChapterShape(
id=None, number=3, title="Epilogue", word_count=2000, sections=[]
),
], ],
tags=[], tags=[],
) )
@@ -469,10 +535,14 @@ class TestDiff(TestCase):
def test_diff_strict_shows_valid_names(self): def test_diff_strict_shows_valid_names(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[], tags=[], chapters=[],
tags=[],
) )
d = shape.diff() d = shape.diff()
with self.assertRaises(AttributeError) as ctx: with self.assertRaises(AttributeError) as ctx:
@@ -506,8 +576,11 @@ class TestDiff(TestCase):
def test_diff_many_batched_query(self): def test_diff_many_batched_query(self):
book2 = Book.objects.create( book2 = Book.objects.create(
title="Warbreaker", isbn="9780765320308", title="Warbreaker",
page_count=592, is_published=True, author=self.author, isbn="9780765320308",
page_count=592,
is_published=True,
author=self.author,
) )
items = [ items = [
FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True), FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True),
@@ -526,7 +599,6 @@ class TestDiff(TestCase):
class TestEdgeCases(TestCase): class TestEdgeCases(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.publisher = Publisher.objects.create(name="Edge Cases Ltd", country="XX") cls.publisher = Publisher.objects.create(name="Edge Cases Ltd", country="XX")
@@ -545,16 +617,22 @@ class TestEdgeCases(TestCase):
def test_boolean_false_is_not_missing(self): def test_boolean_false_is_not_missing(self):
book = Book.objects.create( book = Book.objects.create(
title="Unpublished", isbn="0000000000000", title="Unpublished",
page_count=0, is_published=False, author=self.author, isbn="0000000000000",
page_count=0,
is_published=False,
author=self.author,
) )
results = FlatBookShape.query(lambda qs: qs.filter(pk=book.pk)) results = FlatBookShape.query(lambda qs: qs.filter(pk=book.pk))
self.assertIs(results[0].is_published, False) self.assertIs(results[0].is_published, False)
def test_zero_integer_is_not_missing(self): def test_zero_integer_is_not_missing(self):
book = Book.objects.create( book = Book.objects.create(
title="Empty", isbn="0000000000001", title="Empty",
page_count=0, is_published=False, author=self.author, isbn="0000000000001",
page_count=0,
is_published=False,
author=self.author,
) )
results = BookCardShape.query(lambda qs: qs.filter(pk=book.pk)) results = BookCardShape.query(lambda qs: qs.filter(pk=book.pk))
self.assertEqual(results[0].page_count, 0) self.assertEqual(results[0].page_count, 0)
@@ -562,8 +640,10 @@ class TestEdgeCases(TestCase):
def test_large_queryset(self): def test_large_queryset(self):
books = [ books = [
Book( Book(
title=f"Book {i}", isbn=f"{i:013d}", title=f"Book {i}",
page_count=i * 10, is_published=i % 2 == 0, isbn=f"{i:013d}",
page_count=i * 10,
is_published=i % 2 == 0,
author=self.author, author=self.author,
) )
for i in range(100) for i in range(100)
@@ -574,8 +654,11 @@ class TestEdgeCases(TestCase):
def test_diff_on_boolean_change(self): def test_diff_on_boolean_change(self):
book = Book.objects.create( book = Book.objects.create(
title="Toggle", isbn="1111111111111", title="Toggle",
page_count=100, is_published=False, author=self.author, isbn="1111111111111",
page_count=100,
is_published=False,
author=self.author,
) )
shape = FlatBookShape(id=book.pk, title="Toggle", is_published=True) shape = FlatBookShape(id=book.pk, title="Toggle", is_published=True)
d = shape.diff() d = shape.diff()
@@ -584,8 +667,11 @@ class TestEdgeCases(TestCase):
def test_diff_unchanged_returns_empty(self): def test_diff_unchanged_returns_empty(self):
book = Book.objects.create( book = Book.objects.create(
title="Same", isbn="2222222222222", title="Same",
page_count=200, is_published=True, author=self.author, isbn="2222222222222",
page_count=200,
is_published=True,
author=self.author,
) )
shape = FlatBookShape(id=book.pk, title="Same", is_published=True) shape = FlatBookShape(id=book.pk, title="Same", is_published=True)
d = shape.diff() d = shape.diff()

View File

@@ -0,0 +1,162 @@
"""
Tests for the Mizan SSR bridge and template backend.
Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx.
Tests skip gracefully if Bun is not available.
"""
import os
import shutil
import threading
from django.test import SimpleTestCase, RequestFactory
# Path to the test worker
_SSR_WORKER = os.path.join(
os.path.dirname(__file__),
"..", "..", "..", "..", "..", # up to repo root
"packages", "mizan-ssr", "src", "test-worker.tsx",
)
_SSR_WORKER = os.path.normpath(_SSR_WORKER)
_BUN_AVAILABLE = shutil.which("bun") is not None
_SKIP_MSG = "Bun not available"
class SSRBridgeTests(SimpleTestCase):
"""Tests for the SSR bridge subprocess manager."""
def setUp(self):
if not _BUN_AVAILABLE:
self.skipTest(_SKIP_MSG)
if not os.path.exists(_SSR_WORKER):
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
from mizan.ssr.bridge import SSRBridge
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0)
def tearDown(self):
if hasattr(self, "bridge"):
self.bridge.shutdown()
def test_ping(self):
"""Worker starts and responds to ping."""
self.assertTrue(self.bridge.ping())
def test_render_simple(self):
"""Renders a simple component to HTML."""
result = self.bridge.render("Hello", {"name": "World"})
self.assertIn("Hello,", result.html)
self.assertIn("World", result.html)
def test_render_with_props(self):
"""Renders a component with multiple props."""
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"})
self.assertIn("Alice", result.html)
self.assertIn("42", result.html)
def test_render_missing_component(self):
"""Rendering an unregistered component raises RuntimeError."""
with self.assertRaises(RuntimeError) as ctx:
self.bridge.render("NonExistent", {})
self.assertIn("not registered", str(ctx.exception))
def test_render_error(self):
"""Component that throws during render raises RuntimeError."""
with self.assertRaises(RuntimeError) as ctx:
self.bridge.render("Broken", {})
self.assertIn("Render error", str(ctx.exception))
def test_crash_recovery(self):
"""Bridge restarts the worker if it dies."""
# First render works
result = self.bridge.render("Hello", {"name": "Before"})
self.assertIn("Before", result.html)
# Kill the subprocess
self.bridge._proc.kill()
self.bridge._proc.wait()
# Next render should restart and work
result = self.bridge.render("Hello", {"name": "After"})
self.assertIn("After", result.html)
def test_concurrent_renders(self):
"""Multiple threads can render simultaneously."""
results = {}
errors = {}
def render_in_thread(name: str, idx: int):
try:
result = self.bridge.render("Hello", {"name": name})
results[idx] = result.html
except Exception as e:
errors[idx] = e
threads = []
for i in range(5):
t = threading.Thread(target=render_in_thread, args=(f"User{i}", i))
threads.append(t)
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}")
self.assertEqual(len(results), 5)
for i in range(5):
self.assertIn(f"User{i}", results[i])
class SSRTemplateBackendTests(SimpleTestCase):
"""Tests for the MizanTemplates Django template backend."""
def setUp(self):
if not _BUN_AVAILABLE:
self.skipTest(_SKIP_MSG)
if not os.path.exists(_SSR_WORKER):
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
from mizan.ssr.backend import MizanTemplates
self.engine = MizanTemplates({
"NAME": "mizan-test",
"DIRS": [],
"APP_DIRS": False,
"OPTIONS": {
"worker_path": _SSR_WORKER,
"timeout": 5,
},
})
self.factory = RequestFactory()
def tearDown(self):
if hasattr(self, "engine") and self.engine._bridge is not None:
self.engine._bridge.shutdown()
def test_get_template(self):
"""get_template returns a MizanTemplate."""
from mizan.ssr.backend import MizanTemplate
template = self.engine.get_template("Hello")
self.assertIsInstance(template, MizanTemplate)
self.assertEqual(template.component_name, "Hello")
def test_template_render(self):
"""MizanTemplate.render() produces HTML."""
template = self.engine.get_template("Hello")
html = template.render({"name": "Django"})
self.assertIn("Hello,", html)
self.assertIn("Django", html)
self.assertIn('data-mizan-component="Hello"', html)
def test_template_render_strips_django_internals(self):
"""Django-internal context keys (request, csrf_token) are not passed as props."""
template = self.engine.get_template("Hello")
request = self.factory.get("/")
html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request)
self.assertIn("Test", html)
def test_from_string_raises(self):
"""from_string is not supported."""
from django.template import TemplateDoesNotExist
with self.assertRaises(TemplateDoesNotExist):
self.engine.from_string("<div>Not supported</div>")

View File

@@ -1,13 +1,14 @@
""" """
Djarea URL Configuration mizan URL Configuration
Single integration point for all djarea HTTP endpoints: HTTP endpoints:
- GET /session/ - Initialize session and get CSRF token (for SSR) - GET /session/ - Initialize session and get CSRF token (for SSR)
- POST /call/ - Server function calls (HTTP transport) - POST /call/ - Server function calls (HTTP transport)
- GET /ctx/<name>/ - Bundled context fetch (all functions in a named context)
Security: Security:
- Schema export is NOT exposed over HTTP to prevent API enumeration - Schema export is NOT exposed over HTTP to prevent API enumeration
- Use the management command instead: python manage.py export_djarea_schema - Use the management command instead: python manage.py export_mizan_ir
""" """
from django.http import JsonResponse from django.http import JsonResponse
@@ -15,9 +16,9 @@ from django.middleware.csrf import get_token
from django.urls import path from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from .client.executor import function_call_view from .client.executor import function_call_view, context_fetch_view
app_name = "djarea" app_name = "mizan"
@ensure_csrf_cookie @ensure_csrf_cookie
@@ -37,4 +38,5 @@ def session_init_view(request):
urlpatterns = [ urlpatterns = [
path("session/", session_init_view, name="session-init"), path("session/", session_init_view, name="session-init"),
path("call/", function_call_view, name="function-call"), path("call/", function_call_view, name="function-call"),
path("ctx/<str:context_name>/", context_fetch_view, name="context-fetch"),
] ]

View File

@@ -1,4 +1,8 @@
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.contrib.auth.models import (
AbstractBaseUser,
BaseUserManager,
PermissionsMixin,
)
from django.db import models from django.db import models
@@ -23,7 +27,7 @@ class EmailUserManager(BaseUserManager):
class EmailUser(AbstractBaseUser, PermissionsMixin): class EmailUser(AbstractBaseUser, PermissionsMixin):
"""Minimal user model with email as USERNAME_FIELD. """Minimal user model with email as USERNAME_FIELD.
Matches the calling convention used in djarea's test suite: Matches the calling convention used in mizan's test suite:
User.objects.create_user(email="...", password="...", is_staff=True) User.objects.create_user(email="...", password="...", is_staff=True)
""" """
@@ -90,7 +94,11 @@ class Book(TimestampMixin):
is_published = models.BooleanField(default=False) is_published = models.BooleanField(default=False)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books") author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
editor = models.ForeignKey( editor = models.ForeignKey(
Author, on_delete=models.SET_NULL, null=True, blank=True, related_name="edited_books", Author,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="edited_books",
) )
tags = models.ManyToManyField(Tag, blank=True, related_name="books") tags = models.ManyToManyField(Tag, blank=True, related_name="books")
@@ -112,7 +120,9 @@ class Chapter(TimestampMixin):
class Section(models.Model): class Section(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="sections") chapter = models.ForeignKey(
Chapter, on_delete=models.CASCADE, related_name="sections"
)
heading = models.CharField(max_length=300) heading = models.CharField(max_length=300)
body = models.TextField(default="") body = models.TextField(default="")
position = models.IntegerField(default=0) position = models.IntegerField(default=0)

View File

@@ -1,5 +1,5 @@
""" """
Django settings for running djarea's test suite standalone. Django settings for running mizan's test suite standalone.
Usage: Usage:
cd django/ cd django/
@@ -22,7 +22,7 @@ INSTALLED_APPS = [
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"djarea", "mizan",
"tests", "tests",
] ]

View File

@@ -1,5 +1,5 @@
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("api/djarea/", include("djarea.urls")), path("api/mizan/", include("mizan.urls")),
] ]

View File

@@ -0,0 +1,186 @@
# mizan-fastapi
FastAPI backend adapter for the Mizan protocol. One decorator on a server
function. Typed React client generated. Invalidation automatic.
## Scope
mizan-fastapi targets the **AFI-common subset** — RPC dispatch, context
bundling, JSON-body invalidation, and auth gating. Forms, Channels, Shapes,
and SSR are out of scope for the FastAPI adapter — FastAPI projects use
native equivalents (Pydantic, native WebSockets, ORM-of-choice, FastAPI's
own SSR ecosystem).
## Install
```bash
uv add mizan-fastapi
```
## Setup
```python
# main.py
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from mizan_fastapi import (
MizanError,
mizan_exception_handler,
mizan_validation_handler,
router as mizan_router,
)
app = FastAPI()
app.include_router(mizan_router, prefix="/api/mizan")
app.add_exception_handler(MizanError, mizan_exception_handler)
app.add_exception_handler(RequestValidationError, mizan_validation_handler)
```
The exception handlers render every error path through the Mizan envelope
(`{"error": {"code", "message", "details"}}`) so the kernel's `MizanError`
parses status + code on the frontend regardless of which failure happened.
## Define server functions
```python
from mizan_core.client.function import client
from mizan_core.registry import register
from pydantic import BaseModel
class EchoOutput(BaseModel):
message: str
@client
def echo(request, text: str) -> EchoOutput:
return EchoOutput(message=text)
register(echo, "echo")
```
mizan-fastapi has no auto-discovery (FastAPI doesn't have an app registry
to walk). Register every `@client`-decorated function explicitly. A typical
project keeps registrations in `main.py` (alongside the FastAPI app) or in
a dedicated `clients.py` imported during startup.
## `@client` parameters
```python
@client # plain RPC function
@client(context="global") # singleton context — fetched once, SSR-hydrated
@client(context="user") # named context — fetched per provider mount
@client(affects="user") # mutation — invalidates the user context
@client(affects=user_profile) # mutation — invalidates a specific function
@client(auth=True) # requires authentication
@client(auth="staff") # requires is_staff
@client(auth="superuser") # requires is_superuser
@client(auth=lambda req: ...) # custom predicate
@client(rev=2) # cache revision (busts on bump)
```
`websocket=True`, Forms, and Channels parameters are accepted by the
decorator (they're a `mizan-core` primitive) but ignored by mizan-fastapi —
those features only have effect when paired with mizan-django.
## Auth integration
The executor expects `request.state.user` to be populated by your FastAPI
middleware or dependency tree before dispatch:
```python
from fastapi import Request
@app.middleware("http")
async def attach_user(request: Request, call_next):
request.state.user = await resolve_user_from_token(request)
return await call_next(request)
```
Where `resolve_user_from_token` returns either a user object with
`is_authenticated`, `is_staff`, `is_superuser` attributes, or `None` for an
anonymous request. The executor branches on those for `auth=True`,
`auth="staff"`, `auth="superuser"` requirements.
## Generate the frontend
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:
```toml
# frontend/mizan.toml
output = "src/api"
targets = ["react"]
[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
mizan-generate --config mizan.toml
```
The codegen drives `python -m mizan_fastapi.ir <module>` under the hood,
parses the emitted KDL IR, then emits Stage 1 (typed `callXxx`/`fetchXxx`
over the runtime kernel) + Stage 2 (`<MizanContext>` provider, per-context
providers, `use{Hook}()` hooks) into `src/api/`.
```tsx
// app.tsx
import { MizanContext } from "./api"
export default function App({ children }) {
return <MizanContext baseUrl="/api/mizan">{children}</MizanContext>
}
```
```tsx
// any component
import { useEcho, useCurrentUser } from "./api"
const echo = useEcho()
echo.mutate({ text: "hi" }).then(r => console.log(r.message))
const user = useCurrentUser() // global context — auto-fetched, auto-refreshed on mutation
```
## Running tests
```bash
uv sync --extra dev
uv run pytest
```
## Schema export CLI
For codegen consumption (or any tooling that wants the Mizan schema):
```bash
python -m mizan_fastapi.ir <module>
```
Imports the named module (which must register every `@client` function as
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
mizan-fastapi is one of two reference backend adapters (the other is
`backends/mizan-django`). Both implement the same Mizan protocol on top of
the shared `cores/mizan-python` core (`@client`, registry, MWT, HMAC cache
keys). The AFI conformance suite at `tests/afi/` gates that the two adapters
emit equivalent schemas for the same registered functions. See
`docs/AFI_ARCHITECTURE.md`.
A live e2e harness exercises this adapter end-to-end at
`examples/fastapi-react-site/` (real Chromium → React with generated hooks
→ FastAPI server, 14/14 Playwright tests).

View File

@@ -0,0 +1,33 @@
[project]
name = "mizan-fastapi"
version = "0.1.0"
license = "Elastic-2.0"
description = "Mizan FastAPI backend adapter — HTTP RPC dispatch + context bundling, built on mizan-core."
requires-python = ">=3.10"
dependencies = [
"mizan-core",
"fastapi>=0.110",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mizan_fastapi"]
[tool.uv.sources]
mizan-core = { path = "../../cores/mizan-python", editable = true }
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
python_classes = ["*Tests", "*Test", "Test*"]
python_functions = ["test_*"]

View File

@@ -0,0 +1,54 @@
"""
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
HTTP RPC dispatch and context bundling on top of mizan-core's function
registry. Channels, Forms, Shapes, SSR are out of scope — FastAPI
projects use native equivalents (WebSocket, Pydantic, ORM-of-choice,
SSR frameworks).
Usage:
from fastapi import FastAPI
from mizan_fastapi import router, mizan_exception_handler, MizanError
app = FastAPI()
app.include_router(router, prefix="/api/mizan")
app.add_exception_handler(MizanError, mizan_exception_handler)
# Register your @client-decorated functions
from mizan_core.client.function import client
from mizan_core.registry import register
from .my_functions import echo
register(echo, "echo")
"""
from .executor import (
ErrorCode,
MizanError,
NotFound,
BadRequest,
ValidationFailed,
Unauthorized,
Forbidden,
NotImplementedYet,
InternalError,
compute_invalidation,
execute_function,
)
from .router import router, mizan_exception_handler, mizan_validation_handler
__all__ = [
"router",
"mizan_exception_handler",
"mizan_validation_handler",
"execute_function",
"compute_invalidation",
"ErrorCode",
"MizanError",
"NotFound",
"BadRequest",
"ValidationFailed",
"Unauthorized",
"Forbidden",
"NotImplementedYet",
"InternalError",
]

View File

@@ -0,0 +1,263 @@
"""
RPC dispatch — looks up registered functions, validates input against the
function's Pydantic Input model, executes, and returns the serialized result.
Errors raise typed exceptions (MizanError subclasses). Wire those to JSON
responses by registering `mizan_exception_handler` on the FastAPI app, or
let them propagate to your own handler.
"""
from __future__ import annotations
from enum import Enum
from typing import Any
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ValidationError
from mizan_core.registry import get_context_groups, get_function
from mizan_core.type_utils import types_match_for_merge
# ─── Error taxonomy ─────────────────────────────────────────────────────────
class ErrorCode(str, Enum):
NOT_FOUND = "NOT_FOUND"
BAD_REQUEST = "BAD_REQUEST"
VALIDATION_ERROR = "VALIDATION_ERROR"
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
INTERNAL_ERROR = "INTERNAL_ERROR"
_STATUS = {
ErrorCode.NOT_FOUND: 404,
ErrorCode.BAD_REQUEST: 400,
ErrorCode.VALIDATION_ERROR: 422,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.FORBIDDEN: 403,
ErrorCode.NOT_IMPLEMENTED: 501,
ErrorCode.INTERNAL_ERROR: 500,
}
class MizanError(Exception):
"""Base for protocol-level dispatch errors."""
code: ErrorCode = ErrorCode.INTERNAL_ERROR
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.message = message
self.details = details
@property
def status_code(self) -> int:
return _STATUS[self.code]
class NotFound(MizanError): code = ErrorCode.NOT_FOUND # noqa: E701
class BadRequest(MizanError): code = ErrorCode.BAD_REQUEST # noqa: E701
class ValidationFailed(MizanError): code = ErrorCode.VALIDATION_ERROR # noqa: E701
class Unauthorized(MizanError): code = ErrorCode.UNAUTHORIZED # noqa: E701
class Forbidden(MizanError): code = ErrorCode.FORBIDDEN # noqa: E701
class NotImplementedYet(MizanError): code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
class InternalError(MizanError): code = ErrorCode.INTERNAL_ERROR # noqa: E701
# ─── Auth ───────────────────────────────────────────────────────────────────
def _user(request: Any) -> Any:
return getattr(getattr(request, "state", None), "user", None)
def _is_authenticated(user: Any) -> bool:
return bool(user) and getattr(user, "is_authenticated", True)
def _enforce_auth(request: Any, requirement: Any) -> None:
"""Verify the request meets the function's @client(auth=...) requirement, or raise."""
if requirement is None:
return
user = _user(request)
match requirement:
case True | "required":
if not _is_authenticated(user):
raise Unauthorized("Authentication required")
case "staff":
if not _is_authenticated(user):
raise Unauthorized("Authentication required")
if not getattr(user, "is_staff", False):
raise Forbidden("Staff access required")
case "superuser":
if not _is_authenticated(user):
raise Unauthorized("Authentication required")
if not getattr(user, "is_superuser", False):
raise Forbidden("Superuser access required")
case f if callable(f):
if not f(request):
raise Forbidden("Permission denied")
case other:
raise InternalError(f"Unknown auth requirement: {other!r}")
# ─── Input validation ───────────────────────────────────────────────────────
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
"""Validate input_data against the function's Input model. Returns the instance or None."""
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
return None
fields = input_cls.model_fields
required = [name for name, f in fields.items() if f.is_required()]
if not input_data:
if required:
raise ValidationFailed(
"Input validation failed",
details={"fields": {name: ["Field required"] for name in required}},
)
return input_cls()
if not isinstance(input_data, dict):
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
try:
return input_cls(**input_data)
except ValidationError as e:
raise ValidationFailed(
"Input validation failed",
details={"errors": e.errors()},
) from e
# ─── Dispatch ───────────────────────────────────────────────────────────────
def _resolve_function(fn_name: str) -> Any:
view_class = get_function(fn_name)
if view_class is None:
raise NotFound("Function not found")
if getattr(view_class, "_meta", {}).get("private"):
raise Forbidden("Function is not client-callable")
return view_class
def _serialize(result: Any) -> Any:
# jsonable_encoder walks BaseModel / list / dict recursively, so list[BaseModel]
# (and nested shapes) come out wire-ready without a per-shape branch here.
return jsonable_encoder(result)
async def execute_function(
request: Any,
fn_name: str,
input_data: dict[str, Any] | None = None,
) -> Any:
"""Dispatch a registered function. Returns the serialized result, or raises MizanError.
Awaits `view.acall` — async handlers run on the loop, sync handlers run
in the default threadpool, both via the same entrypoint.
"""
view_class = _resolve_function(fn_name)
_enforce_auth(request, view_class._meta.get("auth"))
view = view_class(request)
validated = _validate_input(view.Input, input_data)
try:
result = await view.acall(validated)
except NotImplementedError as e:
raise NotImplementedYet(str(e) or "Not implemented") from e
except MizanError:
raise
except Exception as e:
raise InternalError(str(e)) from e
return _serialize(result)
# ─── Invalidation ───────────────────────────────────────────────────────────
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
"""Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params."""
affects = getattr(view_class, "_meta", {}).get("affects") or []
return [_invalidation_target(target, input_data or {}) for target in affects]
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
"""Build the `merge` list from @client(merge=...) metadata.
Each entry is `{context, slot, value, params?}` where `slot` names the
function inside the context bundle the value lands in. The slot is
resolved server-side via `types_match_for_merge` so the kernel does
no shape inference — the server has the schema, type-checked routing
lives here. Entries whose slot can't be uniquely resolved are dropped
with a warning; the consumer falls back to refetch via `affects`.
"""
targets = getattr(view_class, "_meta", {}).get("merge") or []
if not targets:
return []
mutation_output = getattr(view_class, "Output", None)
out: list[dict[str, Any]] = []
for ctx_name in targets:
slot = _resolve_merge_slot(ctx_name, mutation_output)
if slot is None:
continue
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result}
scoped = _scoped_params(ctx_name, input_data or {})
if scoped:
entry["params"] = scoped
out.append(entry)
return out
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
"""Find the unique function-name slot whose return type matches the mutation's output.
Returns None on no match or ambiguous match (multiple candidates).
"""
if mutation_output is None:
return None
matches: list[str] = []
for fn_name in get_context_groups().get(context_name, []):
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
fn_output = getattr(fn_cls, "Output", None)
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
matches.append(fn_name)
return matches[0] if len(matches) == 1 else None
def _scoped_params(context_name: str, input_data: dict[str, Any]) -> dict[str, Any]:
"""Match input args against the context's declared Input field names."""
fn_names = get_context_groups().get(context_name, [])
declared: set[str] = set()
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
declared.update(input_cls.model_fields.keys())
return {k: v for k, v in input_data.items() if k in declared}
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
match target.get("type"):
case "context":
name = target["name"]
scoped = _scoped_params(name, input_data)
return {"context": name, "params": scoped} if scoped else name
case "function":
return {"function": target["name"]}
case _:
return target

View File

@@ -0,0 +1,39 @@
"""
Mizan IR (KDL) export CLI for FastAPI backends.
Usage:
python -m mizan_fastapi.ir <module>
Imports the named module (whose import side effects must register every
@client function with `mizan_core.registry`), then writes the canonical
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
directly.
"""
from __future__ import annotations
import importlib
import sys
from mizan_core.ir import build_ir
def main(argv: list[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if len(args) != 1:
print("usage: python -m mizan_fastapi.ir <module>", file=sys.stderr)
return 2
module_name = args[0]
try:
importlib.import_module(module_name)
except Exception as e:
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
return 1
sys.stdout.write(build_ir())
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,109 @@
"""
FastAPI router exposing Mizan's HTTP endpoints:
POST /call/ — RPC dispatch
GET /ctx/{context_name}/ — bundled context fetch
from fastapi import FastAPI
from mizan_fastapi import router, mizan_exception_handler, MizanError
app = FastAPI()
app.include_router(router, prefix="/api/mizan")
app.add_exception_handler(MizanError, mizan_exception_handler)
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from mizan_core.registry import get_context_groups, get_function
from .executor import (
ErrorCode,
MizanError,
NotFound,
compute_invalidation,
compute_merges,
execute_function,
)
router = APIRouter()
def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
return JSONResponse(payload, status_code=status_code, headers={"Cache-Control": "no-store"})
# ─── Endpoints ──────────────────────────────────────────────────────────────
@router.get("/session/")
async def session_init() -> JSONResponse:
"""Session-init probe. Parity with mizan-django's session endpoint.
CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
null token so the response shape stays uniform across backends. The
wire-parity harness uses this endpoint as its readiness probe.
"""
return _no_store({"csrfToken": None})
class CallBody(BaseModel):
fn: str = Field(..., min_length=1)
args: dict[str, Any] = Field(default_factory=dict)
@router.post("/call/")
async def function_call(body: CallBody, request: Request) -> JSONResponse:
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
fn_class = get_function(body.fn)
result = await execute_function(request, body.fn, body.args)
invalidate = compute_invalidation(fn_class, body.args)
merges = compute_merges(fn_class, body.args, result)
payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
if merges:
payload["merge"] = merges
return _no_store(payload)
@router.get("/ctx/{context_name}/")
async def context_fetch(context_name: str, request: Request) -> JSONResponse:
"""Bundled context fetch — `{function_name: result, ...}` for every function in the context."""
fn_names = get_context_groups().get(context_name)
if not fn_names:
raise NotFound(f"Context '{context_name}' not found")
params = dict(request.query_params)
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
return _no_store(bundled)
# ─── Exception handler ──────────────────────────────────────────────────────
async def mizan_exception_handler(_request: Request, exc: MizanError) -> JSONResponse:
"""FastAPI exception handler — renders MizanError to the protocol's error envelope."""
body: dict[str, Any] = {"error": {"code": exc.code.value, "message": exc.message}}
if exc.details:
body["error"]["details"] = exc.details
return _no_store(body, status_code=exc.status_code)
async def mizan_validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
"""Maps malformed request bodies (invalid JSON, missing top-level fields) to BAD_REQUEST."""
return _no_store(
{
"error": {
"code": ErrorCode.BAD_REQUEST.value,
"message": "Invalid request body",
"details": {"errors": exc.errors()},
}
},
status_code=400,
)

View File

@@ -0,0 +1,262 @@
"""End-to-end dispatch tests against a real FastAPI app + TestClient."""
from __future__ import annotations
import asyncio
import pytest
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.testclient import TestClient
from pydantic import BaseModel
from mizan_core.client.function import client
from mizan_core.registry import clear_registry, register
from mizan_fastapi import (
MizanError,
mizan_exception_handler,
mizan_validation_handler,
router as mizan_router,
)
# ─── Fixtures ───────────────────────────────────────────────────────────────
class EchoOutput(BaseModel):
message: str
class SumOutput(BaseModel):
total: int
class UserOutput(BaseModel):
email: str
authenticated: bool
class ItemOutput(BaseModel):
id: int
name: str
@pytest.fixture
def app():
"""Build a fresh FastAPI app + Mizan router with a few @client functions."""
clear_registry()
@client
def echo(request, text: str) -> EchoOutput:
return EchoOutput(message=f"echo: {text}")
@client
def add(request, a: int, b: int) -> SumOutput:
return SumOutput(total=a + b)
@client(context="user")
def current_user(request) -> UserOutput:
return UserOutput(email="anon@example.com", authenticated=False)
@client(context="user")
def user_count(request) -> SumOutput:
return SumOutput(total=42)
@client(affects="user")
def update_email(request, email: str) -> EchoOutput:
return EchoOutput(message=f"updated: {email}")
@client(auth=True)
def whoami(request) -> UserOutput:
return UserOutput(email="real@example.com", authenticated=True)
@client
def list_items(request) -> list[ItemOutput]:
return [ItemOutput(id=1, name="a"), ItemOutput(id=2, name="b")]
@client
def find_item(request, item_id: int) -> ItemOutput | None:
return ItemOutput(id=item_id, name="found") if item_id > 0 else None
@client(merge="items")
def set_item_name(request, id: int, name: str) -> ItemOutput:
return ItemOutput(id=id, name=name)
@client(context="items")
def items_list(request) -> list[ItemOutput]:
return [ItemOutput(id=1, name="orig")]
@client
async def async_echo(request, text: str) -> EchoOutput:
# await something on the loop to prove we're really running async
await asyncio.sleep(0)
return EchoOutput(message=f"async: {text}")
register(echo, "echo")
register(add, "add")
register(current_user, "current_user")
register(user_count, "user_count")
register(update_email, "update_email")
register(whoami, "whoami")
register(list_items, "list_items")
register(find_item, "find_item")
register(set_item_name, "set_item_name")
register(items_list, "items_list")
register(async_echo, "async_echo")
fastapi_app = FastAPI()
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
fastapi_app.add_exception_handler(RequestValidationError, mizan_validation_handler)
yield fastapi_app
clear_registry()
@pytest.fixture
def http(app):
return TestClient(app)
# ─── RPC dispatch ───────────────────────────────────────────────────────────
class FunctionCallTests:
def test_simple_call_returns_result(self, http):
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "hi"}})
assert r.status_code == 200
body = r.json()
assert body["result"]["message"] == "echo: hi"
assert body["invalidate"] == []
def test_call_with_typed_input(self, http):
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": 2, "b": 3}})
assert r.status_code == 200
assert r.json()["result"]["total"] == 5
def test_unknown_function_returns_not_found(self, http):
r = http.post("/api/mizan/call/", json={"fn": "ghost"})
assert r.status_code == 404
assert r.json()["error"]["code"] == "NOT_FOUND"
def test_validation_error_returns_422(self, http):
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": "not-int", "b": 3}})
assert r.status_code == 422
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
def test_missing_required_input_returns_validation_error(self, http):
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {}})
assert r.status_code == 422
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
def test_missing_fn_field_returns_400(self, http):
r = http.post("/api/mizan/call/", json={})
assert r.status_code == 400
assert r.json()["error"]["code"] == "BAD_REQUEST"
def test_invalid_json_returns_400(self, http):
r = http.post("/api/mizan/call/", content=b"not json", headers={"content-type": "application/json"})
assert r.status_code == 400
def test_response_carries_no_store(self, http):
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "x"}})
assert r.headers.get("cache-control") == "no-store"
# ─── Context bundling ───────────────────────────────────────────────────────
class ContextFetchTests:
def test_context_returns_bundled_results(self, http):
r = http.get("/api/mizan/ctx/user/")
assert r.status_code == 200
body = r.json()
assert "current_user" in body
assert "user_count" in body
assert body["current_user"]["email"] == "anon@example.com"
assert body["user_count"]["total"] == 42
def test_unknown_context_returns_not_found(self, http):
r = http.get("/api/mizan/ctx/ghost/")
assert r.status_code == 404
assert r.json()["error"]["code"] == "NOT_FOUND"
# ─── Invalidation ───────────────────────────────────────────────────────────
class AuthTests:
"""The decorator normalizes auth=True → meta['auth']='required'; executor must match both."""
def test_anonymous_request_to_auth_required_returns_401(self, http):
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
assert r.status_code == 401
assert r.json()["error"]["code"] == "UNAUTHORIZED"
class InvalidationTests:
def test_mutation_emits_invalidate_list(self, http):
r = http.post(
"/api/mizan/call/",
json={"fn": "update_email", "args": {"email": "new@example.com"}},
)
assert r.status_code == 200
body = r.json()
# affects='user' is a context-name string → invalidate list contains 'user'
assert "user" in body["invalidate"]
# ─── Structured-output shapes ───────────────────────────────────────────────
class StructuredOutputTests:
"""list[BaseModel] and Optional[BaseModel] should reach the wire as bare values, not {result: ...}."""
def test_list_of_basemodel_returns_bare_array(self, http):
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
assert r.status_code == 200
body = r.json()
assert body["result"] == [
{"id": 1, "name": "a"},
{"id": 2, "name": "b"},
]
def test_optional_basemodel_returns_inner_or_none(self, http):
r_found = http.post("/api/mizan/call/", json={"fn": "find_item", "args": {"item_id": 5}})
assert r_found.status_code == 200
assert r_found.json()["result"] == {"id": 5, "name": "found"}
r_missing = http.post("/api/mizan/call/", json={"fn": "find_item", "args": {"item_id": 0}})
assert r_missing.status_code == 200
assert r_missing.json()["result"] is None
# ─── Merge protocol ─────────────────────────────────────────────────────────
class AsyncHandlerTests:
"""`async def` handlers dispatch on the loop via view.acall."""
def test_async_handler_returns_awaited_result(self, http):
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
assert r.status_code == 200
assert r.json()["result"] == {"message": "async: hello"}
class MergeTests:
"""@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch."""
def test_merge_target_emits_merge_entry(self, http):
r = http.post(
"/api/mizan/call/",
json={"fn": "set_item_name", "args": {"id": 42, "name": "renamed"}},
)
assert r.status_code == 200
body = r.json()
# Server resolves slot — items_list returns list[ItemOutput], mutation returns ItemOutput
assert body["merge"] == [
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
]
# invalidate stays empty when only merge is declared
assert body["invalidate"] == []

591
backends/mizan-rust-axum/Cargo.lock generated Normal file
View File

@@ -0,0 +1,591 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linkme"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf"
dependencies = [
"linkme-impl",
]
[[package]]
name = "linkme-impl"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "mizan-axum"
version = "0.1.0"
dependencies = [
"axum",
"mizan-core",
"serde",
"serde_json",
"tokio",
"tower",
"tower-http",
]
[[package]]
name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"linkme",
"mizan-macros",
"serde",
"serde_json",
]
[[package]]
name = "mizan-macros"
version = "0.1.0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[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 = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
]
[[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 = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags",
"bytes",
"http",
"http-body",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -0,0 +1,15 @@
[package]
name = "mizan-axum"
version = "0.1.0"
edition = "2021"
description = "axum HTTP adapter for Mizan — typed RPC dispatch + context-bundle fetch on top of mizan-core's compile-time function registry."
license = "Elastic-2.0"
[dependencies]
mizan-core = { path = "../../cores/mizan-rust" }
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace"] }

View File

@@ -0,0 +1,27 @@
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
//! with a Cache-Control: no-store header.
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::MizanError;
pub struct ApiError(pub MizanError);
impl From<MizanError> for ApiError {
fn from(e: MizanError) -> Self {
Self(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.0.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut resp = (status, Json(self.0.to_json())).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp
}
}

View File

@@ -0,0 +1,162 @@
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::{
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::any::Any;
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::errors::ApiError;
/// Type-erased application state threaded into every `dispatch()` call via
/// `RequestHandle`. User handlers downcast to their concrete state type.
/// `Arc` keeps the clone cheap across per-request handler invocations.
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
/// Body for POST /call/. Matches the Python `CallBody` shape.
#[derive(Debug, Deserialize)]
pub struct CallBody {
pub fn_: Option<String>,
#[serde(rename = "fn")]
pub function_name: Option<String>,
#[serde(default)]
pub args: Map<String, Value>,
}
impl CallBody {
fn resolved_name(&self) -> Option<&str> {
self.function_name
.as_deref()
.or(self.fn_.as_deref())
}
}
#[derive(Debug, Serialize)]
pub struct CallResponse {
pub result: Value,
pub invalidate: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub merge: Option<Vec<Value>>,
}
fn no_store(json: Value) -> Response {
let mut resp = (StatusCode::OK, Json(json)).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp
}
/// POST /call/ — RPC dispatch.
pub async fn function_call(
State(app_state): State<AppStateAny>,
Json(body): Json<CallBody>,
) -> Result<Response, ApiError> {
let fn_name = body
.resolved_name()
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
.to_string();
let fn_spec = lookup_function(&fn_name)
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let merges = compute_merges(fn_spec, &body.args, &result);
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
let payload = CallResponse {
result,
invalidate,
merge: merge_payload,
};
Ok(no_store(serde_json::to_value(&payload).unwrap()))
}
/// GET /ctx/:context_name/ — bundled context fetch.
pub async fn context_fetch(
State(app_state): State<AppStateAny>,
Path(context_name): Path<String>,
Query(params): Query<BTreeMap<String, String>>,
) -> Result<Response, ApiError> {
if lookup_context(&context_name).is_none() {
return Err(ApiError(MizanError::NotFound(format!(
"context {context_name:?} not registered"
))));
}
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(&context_name))
.collect();
if members.is_empty() {
return Err(ApiError(MizanError::NotFound(format!(
"context {context_name:?} has no registered members"
))));
}
// Convert query params (all-string values) to the JSON arg map. Numeric
// params get parsed via the per-function input_params primitive table.
let mut bundled = Map::new();
for fn_spec in &members {
let args = coerce_query_args(*fn_spec, &params);
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
bundled.insert(fn_spec.name().to_string(), result);
}
Ok(no_store(Value::Object(bundled)))
}
/// Coerce string-valued query params into typed JSON values using the
/// function's declared input_params. Strings that don't parse stay as
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
fn coerce_query_args(
fn_spec: &dyn FunctionSpec,
params: &BTreeMap<String, String>,
) -> Map<String, Value> {
let mut out = Map::new();
for ip in fn_spec.input_params() {
if let Some(raw) = params.get(ip.name) {
let parsed = match ip.primitive {
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
serde_json::Number::from_f64(v).map(Value::Number)
}),
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
};
if let Some(v) = parsed {
out.insert(ip.name.into(), v);
} else {
out.insert(ip.name.into(), Value::from(raw.clone()));
}
}
}
out
}
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
/// readiness-probe consumers see a well-formed response.
pub async fn session_init() -> Response {
let body = serde_json::json!({ "csrfToken": null });
no_store(body)
}

View File

@@ -0,0 +1,58 @@
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
//!
//! Usage:
//! ```ignore
//! use axum::Router;
//! use mizan_axum::router;
//!
//! #[tokio::main]
//! async fn main() {
//! let app = Router::new().nest("/api/mizan", router());
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
//! axum::serve(listener, app).await.unwrap();
//! }
//! ```
//!
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
//! * `GET /session/` — session-init probe (placeholder CSRF token)
//! * `POST /call/` — RPC dispatch with invalidate+merge response
//! * `GET /ctx/:name/` — bundled context fetch
mod errors;
mod handlers;
pub use errors::ApiError;
pub use handlers::{
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
};
use axum::routing::{get, post};
use axum::Router;
use std::any::Any;
use std::sync::Arc;
/// Build the Mizan router with user-supplied app state. The state is
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
/// dispatch via `RequestHandle`. Handlers downcast to their concrete state
/// type.
///
/// Mount under a prefix:
/// `Router::new().nest("/api/mizan", router(my_state))`.
pub fn router<S>(state: S) -> Router
where
S: Any + Send + Sync + 'static,
{
let state: AppStateAny = Arc::new(state);
Router::new()
.route("/session/", get(handlers::session_init))
.route("/call/", post(handlers::function_call))
.route("/ctx/:context_name/", get(handlers::context_fetch))
.with_state(state)
}
/// Router variant for callers that have no app state to thread — the
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
/// and other stateless test apps.
pub fn router_stateless() -> Router {
router(())
}

4621
backends/mizan-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
[package]
name = "mizan-tauri"
version = "0.1.0"
edition = "2021"
description = "Tauri backend adapter for Mizan — typed RPC dispatch over Tauri's IPC. Single `mizan_invoke` command routes through mizan-core's compile-time function registry."
license = "Elastic-2.0"
[dependencies]
mizan-core = { path = "../../cores/mizan-rust" }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,290 @@
# mizan-tauri
Tauri backend adapter for the Mizan protocol. One plugin call on the Rust
side. `#[mizan::client]` on async functions. Typed React client generated.
Invalidation automatic — same protocol surface as mizan-fastapi /
mizan-django / mizan-rust-axum, routed through Tauri's IPC instead of HTTP.
## Scope
mizan-tauri targets the **AFI-common subset** — RPC dispatch, context
bundling, server-driven invalidation/merge. The transport channel is
Tauri's `invoke()`; the dispatch table is the linkme-backed `FUNCTIONS`
slice from `mizan-core`. No HTTP server is involved — the Tauri runtime
handles message framing, the plugin handles dispatch.
Forms / SSR / Channels are out of scope (those are Django-side primitives).
Tauri apps using mizan-tauri get RPC + context bundling + invalidation,
nothing more.
## Install
```toml
# src-tauri/Cargo.toml
[dependencies]
tauri = "2"
mizan-core = { path = "../../mizan/cores/mizan-rust" }
mizan-tauri = { path = "../../mizan/backends/mizan-tauri" }
serde = { version = "1", features = ["derive"] }
```
```jsonc
// package.json
{
"dependencies": {
"@mizan/base": "file:../mizan/frontends/mizan-base",
"@mizan/tauri-transport": "file:../mizan/frontends/mizan-tauri-transport",
"@tauri-apps/api": "^2"
}
}
```
## Setup — Rust
Install the plugin on the Tauri builder. The plugin registers a single
command (`plugin:mizan|mizan_invoke`) that routes call/fetch envelopes
through the function registry. No per-function `#[tauri::command]` is
needed; the macro-emitted FunctionSpec IS the dispatch table.
```rust
// src-tauri/src/lib.rs
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(mizan_tauri::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
`commands` must be reachable from the binary's link graph — `mod
commands;` works (private mod stays linked because `lib.rs` references
it through file inclusion). If a separate binary (e.g. the IR-export
bin below) also needs to see the registrations, mark it `pub mod
commands;` so the integration-test / sibling-binary path can force-link.
## Define server functions
```rust
// src-tauri/src/commands.rs
use mizan_core::{self as mizan, MizanError, RequestHandle};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, mizan_core::Mizan)]
pub struct Greeting {
pub message: String,
}
#[mizan::client]
pub async fn greet(_req: &RequestHandle<'_>, name: String) -> Greeting {
Greeting { message: format!("hello, {name}") }
}
// Result<T, MizanError> is supported when the function can fail; the
// dispatch wrapper `?`-unwraps it so server-side errors surface as the
// protocol's standard {code, message, details?} envelope.
#[mizan::client]
pub async fn read_file(
_req: &RequestHandle<'_>,
path: String,
) -> Result<Greeting, MizanError> {
let body = std::fs::read_to_string(&path)
.map_err(|e| MizanError::NotFound(e.to_string()))?;
Ok(Greeting { message: body })
}
```
`#[mizan::client]` parameters mirror the other backends — `context = …`,
`affects = …`, `merge = …`, `private`. See `mizan-rust-axum`'s README for
the full set.
### App-state access
The first parameter is `req: &RequestHandle<'_>` — the same handle the
HTTP adapter threads through. Inside a Tauri-mounted plugin, the handle
wraps `tauri::AppHandle`, so user functions can downcast for access to
Tauri's managed-state container or event emission:
```rust
#[mizan::client]
pub async fn store_value(req: &RequestHandle<'_>, key: String) -> Greeting {
let app = req.downcast::<tauri::AppHandle>()
.expect("Tauri AppHandle threaded by mizan-tauri");
// app.state::<MyState>(), app.emit(...), etc.
Greeting { message: format!("stored {key}") }
}
```
Stateless functions ignore the handle (`_req: &RequestHandle<'_>`).
## IR export binary
mizan-generate needs the consumer crate's IR. Add a small bin that
references each `#[mizan::client]` function (so linkme keeps the
distributed slice's entries) and prints `mizan_core::build_ir()`:
```rust
// src-tauri/src/bin/emit_mizan_ir.rs
//
// Cargo.toml adds:
// [[bin]]
// name = "emit-mizan-ir"
// path = "src/bin/emit_mizan_ir.rs"
//
// linkme only collects from translation units that survive
// dead-code elimination; this fn names one item per file carrying
// #[derive(Mizan)] / #[mizan::client] registrations so the linker
// keeps them in the final binary.
#[allow(dead_code)]
fn _force_link() {
use my_app_lib::commands;
let _ = commands::greet;
let _ = commands::read_file;
// ... one per #[mizan::client] function
}
fn main() {
_force_link();
print!("{}", mizan_core::build_ir());
}
```
## Generate the frontend
```toml
# mizan.toml at the project root
project_id = "my-tauri-app"
output = "src/api"
targets = ["react"]
[source.rust]
manifest_path = "src-tauri/Cargo.toml"
bin = "emit-mizan-ir"
# Optional — author the Rust types from Pydantic models via decoru.
# Omit this block for pure-Rust usage.
[source.rust.pydantic]
module = "my_app.schema"
output = "src-tauri/src/schema.rs"
command = ["uv", "run", "python"] # any python with `decoru` importable
header = """\
// AUTO-GENERATED by mizan-generate (source.rust.pydantic step).
// Source of truth: my_app/schema.py.
// DO NOT EDIT BY HAND. Regenerate with: `mizan-generate`
use serde::{Deserialize, Serialize};
"""
```
```bash
mizan-generate --config mizan.toml
```
The Pydantic pre-step auto-discovers `BaseModel` subclasses AND `Enum`
subclasses declared in the named module; decoru emits the structs, and a
small inline emitter renders enums (PascalCase variants from Python
member names, `#[serde(rename_all = "snake_case")]`, `#[default]` on the
last variant so decoru's `impl Default` keeps compiling).
The Rust step then runs `cargo run --bin emit-mizan-ir`, parses the
emitted KDL, and dispatches the configured `targets` to their emitters
(`stage1` → typed `callXxx`/`fetchXxx`; `react``<MizanContext>` +
per-context providers + `use{Hook}()` hooks).
## Setup — TS
```tsx
// src/main.tsx
import { configure } from "@mizan/base";
import { tauriTransport } from "@mizan/tauri-transport";
// Route every mizanCall / mizanFetch through Tauri's IPC. Must run
// before any generated callXxx() executes — top-level at the module
// entry is the safe place.
configure({ transport: tauriTransport() });
```
```tsx
// any component
import { callGreet } from "@/api";
const greeting = await callGreet({ name: "world" });
console.log(greeting.message);
```
For framework hooks generated by Stage 2 (`useGreet()` etc., wrapping the
imperative `callGreet` with `isPending`/`error` state), wrap your tree
with `<MizanContext>` at the root — same as the HTTP-transport setup. The
generated provider is transport-agnostic; it reads from `config.transport`
the kernel is using.
### tsconfig / vite preserve symlinks
The `@mizan/*` packages are typically linked via `file:` in package.json.
Without `preserveSymlinks`, both TypeScript and Vite/Rollup follow the
symlinks to their real location and fail to resolve the linked packages'
peer dependencies (`@tauri-apps/api`, `@mizan/base`) from there.
```jsonc
// tsconfig.json
{
"compilerOptions": {
"moduleResolution": "bundler",
"preserveSymlinks": true,
// …
}
}
```
```ts
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
preserveSymlinks: true,
},
// …
});
```
## Wire protocol
Same envelope as the HTTP adapter, wrapped in a Tauri invoke payload:
```ts
// call
invoke('plugin:mizan|mizan_invoke', {
envelope: { op: 'call', fn: 'greet', args: { name: 'world' } }
})
// → { result: { message: "hello, world" }, invalidate: [], merge?: [...] }
// fetch (context bundling)
invoke('plugin:mizan|mizan_invoke', {
envelope: { op: 'fetch', context: 'user', params: { user_id: 42 } }
})
// → { user_profile: {...}, user_orders: [...] } (flat bundle)
```
Errors flow through Tauri's `Promise.reject` path; `@mizan/tauri-transport`
re-wraps them into the same `MizanError` shape the HTTP transport
produces, so consumer code is identical regardless of transport.
## Reference application
`claude-manage` is the production reference — Tauri + React + Pydantic
schema + Mizan RPC. See `~/dev/claude-manage/mizan.toml` and
`~/dev/claude-manage/src-tauri/src/commands.rs` for a full migrated app.
## Architecture
mizan-tauri shares `cores/mizan-rust` with `mizan-rust-axum`. Both
adapters dispatch through the same compile-time `FUNCTIONS` registry,
same `compute_invalidation` / `compute_merges` logic, same KDL IR
emitted by `build_ir()`. The only difference is the wire surface — axum
takes POST `/call/` and GET `/ctx/:name/`, mizan-tauri takes a single
`mizan_invoke` command with an op-tagged envelope.

View File

@@ -0,0 +1,220 @@
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
//!
//! Ships as a Tauri plugin. The consumer installs it with one line:
//!
//! ```ignore
//! tauri::Builder::default()
//! .plugin(mizan_tauri::init())
//! .run(tauri::generate_context!())
//! .expect("error while running tauri application");
//! ```
//!
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
//! sends call/fetch envelopes to it; the dispatch routes through
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
//! consumes. There is no per-function tauri::command; the registry IS
//! the dispatch table.
//!
//! Wire envelope:
//!
//! ```json
//! { "op": "call", "fn": "list_sessions", "args": {} }
//! { "op": "fetch", "context": "session", "params": {} }
//! ```
//!
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
//! mizan-rust-axum:
//!
//! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//!
//! Error responses come back as the `Err` variant of the Tauri command's
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
//! The TS-side transport re-wraps it into a `MizanError` so consumers
//! see one error surface regardless of transport.
use mizan_core::{
compute_invalidation, compute_merges, lookup_context, lookup_function,
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::<R>::new("mizan")
.invoke_handler(tauri::generate_handler![mizan_invoke])
.build()
}
// === Wire envelope ===
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
/// Tauri's serde deserializer pulls this struct out of the `envelope`
/// field of the invoke payload.
#[derive(Debug, Deserialize)]
#[serde(tag = "op")]
pub enum Envelope {
#[serde(rename = "call")]
Call {
/// Wire-level function name — registered name on the Rust side.
#[serde(rename = "fn")]
function_name: String,
#[serde(default)]
args: Map<String, Value>,
},
#[serde(rename = "fetch")]
Fetch {
context: String,
#[serde(default)]
params: Map<String, Value>,
},
}
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
/// this and constructs a `MizanError`.
#[derive(Debug, Serialize)]
pub struct ErrorPayload {
pub code: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl From<MizanError> for ErrorPayload {
fn from(e: MizanError) -> Self {
let details = if let MizanError::ValidationFailed { details, .. } = &e {
Some(details.clone())
} else {
None
};
Self {
code: e.code(),
message: e.message().to_string(),
details,
}
}
}
// === Dispatch ===
/// The single Mizan dispatch command. Registered on the plugin's invoke
/// handler — the consumer never wires it directly.
///
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
/// it into a `RequestHandle` so `#[mizan::client]` functions can
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
/// emission. Stateless functions ignore the handle.
#[tauri::command]
async fn mizan_invoke<R: Runtime>(
app: tauri::AppHandle<R>,
envelope: Envelope,
) -> Result<Value, ErrorPayload> {
match envelope {
Envelope::Call {
function_name,
args,
} => handle_call(&app, &function_name, args).await,
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
}
}
async fn handle_call<R: Runtime>(
app: &tauri::AppHandle<R>,
fn_name: &str,
args: Map<String, Value>,
) -> Result<Value, ErrorPayload> {
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
ErrorPayload::from(MizanError::NotFound(format!(
"function {fn_name:?} not registered"
)))
})?;
let req = RequestHandle::new(app);
let result = fn_spec
.dispatch(req, Value::Object(args.clone()))
.await
.map_err(ErrorPayload::from)?;
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let merges = compute_merges(fn_spec, &args, &result);
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
let mut payload = json!({
"result": result,
"invalidate": invalidate,
});
if let Some(merge) = merge_payload {
payload
.as_object_mut()
.expect("payload is a JSON object")
.insert("merge".into(), Value::Array(merge));
}
Ok(payload)
}
async fn handle_fetch<R: Runtime>(
app: &tauri::AppHandle<R>,
context_name: &str,
params: Map<String, Value>,
) -> Result<Value, ErrorPayload> {
if lookup_context(context_name).is_none() {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
"context {context_name:?} not registered"
))));
}
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(context_name))
.collect();
if members.is_empty() {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
"context {context_name:?} has no registered members"
))));
}
let mut bundled = Map::new();
for fn_spec in &members {
let args = filter_args(*fn_spec, &params);
let req = RequestHandle::new(app);
let result = fn_spec
.dispatch(req, Value::Object(args))
.await
.map_err(ErrorPayload::from)?;
bundled.insert(fn_spec.name().to_string(), result);
}
Ok(Value::Object(bundled))
}
/// Filter the envelope's params down to keys this function declares as
/// input. The HTTP/axum adapter coerces string-typed query params to
/// JSON primitives in the equivalent step; the Tauri arg channel already
/// carries typed JSON, so the filter is sufficient on its own.
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
let mut out = Map::new();
for ip in fn_spec.input_params() {
if let Some(v) = params.get(ip.name) {
out.insert(ip.name.into(), v.clone());
}
}
out
}

View File

@@ -0,0 +1,19 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@mizan/ts",
"devDependencies": {
"bun-types": "latest",
},
},
},
"packages": {
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@mizan/ts",
"version": "0.1.0",
"description": "Mizan TypeScript backend adapter — server functions, context bundling, invalidation protocol.",
"type": "module",
"main": "src/index.ts",
"scripts": {
"test": "bun test"
},
"devDependencies": {
"bun-types": "latest"
},
"license": "Elastic-2.0"
}

44
backends/mizan-ts/src/cache/backend.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
/**
* Cache backends — MemoryCache for testing.
*
* Simple key-value store. No reverse indexes.
*/
export interface CacheBackend {
get(key: string): string | null
set(key: string, value: string): void
delete(key: string): boolean
deleteByPrefix(prefix: string): number
clear(): void
}
export class MemoryCache implements CacheBackend {
private _store = new Map<string, string>()
get(key: string): string | null {
return this._store.get(key) ?? null
}
set(key: string, value: string): void {
this._store.set(key, value)
}
delete(key: string): boolean {
return this._store.delete(key)
}
deleteByPrefix(prefix: string): number {
let count = 0
for (const key of [...this._store.keys()]) {
if (key.startsWith(prefix)) {
this._store.delete(key)
count++
}
}
return count
}
clear(): void {
this._store.clear()
}
}

72
backends/mizan-ts/src/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,72 @@
/**
* mizan cache — TypeScript adapter.
*
* Same protocol as Python's mizan.cache. Cross-language conformance
* verified by pin tests. No reverse indexes — scoped purge recomputes
* the key directly, broad purge uses prefix scan.
*/
export { MemoryCache } from './backend'
export type { CacheBackend } from './backend'
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
import type { CacheBackend } from './backend'
import { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
let _cacheInstance: CacheBackend | null = null
export function getCache(): CacheBackend | null {
return _cacheInstance
}
export function setCache(backend: CacheBackend | null): void {
_cacheInstance = backend
}
export function resetCache(): void {
_cacheInstance = null
}
export function cacheGet(
secret: string,
backend: CacheBackend,
context: string,
params: Record<string, any>,
userId?: string,
rev: number = 0,
): string | null {
const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.get(key)
}
export function cachePut(
secret: string,
backend: CacheBackend,
context: string,
params: Record<string, any>,
value: string,
userId?: string,
rev: number = 0,
): void {
const key = deriveCacheKey(secret, context, params, userId, rev)
backend.set(key, value)
}
export function cachePurge(
backend: CacheBackend,
context: string,
params?: Record<string, any> | null,
secret?: string | null,
userId?: string,
rev: number = 0,
): number {
if (params && secret) {
// Scoped purge — recompute key and delete directly
const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.delete(key) ? 1 : 0
} else {
// Broad purge — prefix scan
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
return backend.deleteByPrefix(prefix)
}
}

57
backends/mizan-ts/src/cache/keys.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
/**
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
*
* Protocol-critical: must produce identical output to Python's derive_cache_key.
* Cross-language conformance verified by pin tests.
*
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
*/
import { createHmac } from 'crypto'
const CONTEXT_KEY_PREFIX = 'ctx:'
/**
* JSON.stringify with recursively sorted keys and no whitespace.
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
*/
function stableStringify(obj: any): string {
if (obj === null || obj === undefined) return 'null'
if (typeof obj === 'string') return JSON.stringify(obj)
if (typeof obj === 'number' || typeof obj === 'boolean') return String(obj)
if (Array.isArray(obj)) {
return '[' + obj.map(stableStringify).join(',') + ']'
}
const keys = Object.keys(obj).sort()
const pairs = keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k]))
return '{' + pairs.join(',') + '}'
}
/**
* Derive a deterministic HMAC-SHA256 cache key.
*
* Returns "ctx:{context}:{hmac_hex}".
*/
export function deriveCacheKey(
secret: string,
context: string,
params: Record<string, any>,
userId?: string,
rev: number = 0,
): string {
const sortedParams: Record<string, string> = {}
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
sortedParams[k] = String(v)
}
const keyData: Record<string, any> = { c: context, p: sortedParams, r: rev }
if (userId !== undefined) {
keyData.u = String(userId)
}
const message = stableStringify(keyData)
const hmacHex = createHmac('sha256', secret).update(message).digest('hex')
return `${CONTEXT_KEY_PREFIX}${context}:${hmacHex}`
}
export { CONTEXT_KEY_PREFIX }

View File

@@ -0,0 +1,140 @@
/**
* Mizan @client decorator and function wrapper.
*
* Two registration styles:
*
* 1. Function wrapper (standalone functions):
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*
* 2. Class decorator (methods):
* class Handlers {
* @client({ context: UserCtx })
* async userProfile(userId: number) { ... }
* }
*/
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
import { register } from './registry'
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
if (ctx instanceof ReactContext) return ctx.name
return ctx
}
function normalizeAffects(
affects: ClientOptions['affects'],
): RegistryEntry['affects'] | undefined {
if (!affects) return undefined
const items = Array.isArray(affects) ? affects : [affects]
return items.map(item => {
if (item instanceof ReactContext) {
return { type: 'context' as const, name: item.name }
}
return { type: 'context' as const, name: item }
})
}
function extractParams(fn: Function): ParamDef[] {
// Extract parameter names from function.toString()
const source = fn.toString()
const match = source.match(/\(([^)]*)\)/)
if (!match || !match[1].trim()) return []
return match[1]
.split(',')
.map(p => p.trim())
.filter(p => p && !p.startsWith('...'))
.map(p => {
// Handle destructured defaults: name = default, name: type
const name = p.split(/[=:]/)[0].trim()
return { name, type: 'any', required: !p.includes('=') }
})
}
/**
* Function wrapper — registers a standalone function.
*
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*/
export function client<T extends (...args: any[]) => Promise<any>>(
options: ClientOptions,
fn: T,
): T
/**
* Class method decorator.
*
* class Handlers {
* @client({ context: UserCtx })
* async userProfile(userId: number) { ... }
* }
*/
export function client(options: ClientOptions): MethodDecorator
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
// Function wrapper form: client(options, fn)
if (fn && typeof fn === 'function') {
const options = optionsOrFn as ClientOptions
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
const name = fn.name || 'anonymous'
const params = extractParams(fn)
const isView = false // Determined at call time for function wrappers
const entry: RegistryEntry = {
name,
fn: fn as any,
context,
affects,
params,
private: options.private ?? false,
viewPath: isView,
route: options.route,
methods: options.methods,
auth: options.auth,
rev: options.rev,
cache: options.cache,
}
register(entry)
return fn
}
// Decorator form: @client(options)
const options = optionsOrFn as ClientOptions
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
const params = extractParams(originalMethod)
const entry: RegistryEntry = {
name: propertyKey,
fn: originalMethod,
context,
affects,
params,
private: options.private ?? false,
viewPath: false,
route: options.route,
methods: options.methods,
auth: options.auth,
rev: options.rev,
cache: options.cache,
}
register(entry)
return descriptor
}
}

View File

@@ -0,0 +1,209 @@
/**
* Request dispatch — context GET and mutation POST handlers.
*
* Framework-agnostic. Returns plain objects. The router adapter
* (Express, Hono, etc.) converts to framework-specific responses.
*/
import { getFunction, getContextGroups } from './registry'
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
let _cacheSecret: string | null = null
/** Set the cache secret for origin-side caching. */
export function setCacheSecret(secret: string | null): void {
_cacheSecret = secret
}
export interface MizanResponse {
status: number
body: any
headers: Record<string, string>
}
/**
* Handle GET /api/mizan/ctx/:contextName/
*
* Bundles all functions in a named context into one response.
*/
export async function handleContextFetch(
contextName: string,
params: Record<string, string>,
): Promise<MizanResponse> {
const groups = getContextGroups()
const fnNames = groups[contextName]
if (!fnNames) {
return {
status: 404,
body: { error: true, code: 'NOT_FOUND', message: `Context '${contextName}' not found` },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
// Resolve effective rev (max across functions) and cache policy (min TTL)
let effectiveRev = 0
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (entry?.rev) effectiveRev = Math.max(effectiveRev, entry.rev)
}
// Origin-side cache lookup
const cacheBackend = getCache()
const cacheSecret = _cacheSecret
if (cacheBackend && cacheSecret) {
try {
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
if (cached !== null) {
return {
status: 200,
body: JSON.parse(cached),
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
}
}
} catch { /* cache miss on error */ }
}
const results: Record<string, any> = {}
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
// Filter params to only those this function declares
const fnParams: Record<string, any> = {}
for (const p of entry.params) {
if (p.name in params) fnParams[p.name] = params[p.name]
}
try {
const argValues = entry.params.map(p => fnParams[p.name])
const result = await entry.fn(...argValues)
// View path — skip (context GET is for RPC data)
if (result instanceof Response) continue
results[fnName] = result
} catch (e: any) {
return {
status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
}
// Resolve effective cache policy for origin-side cache decision
let effectiveCache: number | boolean = true
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
if (entry.cache === false) { effectiveCache = false; break }
if (typeof entry.cache === 'number') {
effectiveCache = effectiveCache === true
? entry.cache
: Math.min(effectiveCache as number, entry.cache)
}
}
// Store in origin-side cache (skip if cache=False)
if (cacheBackend && cacheSecret && effectiveCache !== false) {
try {
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
} catch { /* cache store failure is non-fatal */ }
}
return {
status: 200,
body: results,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
},
}
}
/**
* Handle POST /api/mizan/call/
*
* Dispatches to a named function. Returns result + invalidation.
*/
export async function handleMutationCall(
fnName: string,
args: Record<string, any>,
): Promise<MizanResponse> {
const entry = getFunction(fnName)
if (!entry) {
return {
status: 404,
body: { error: true, code: 'NOT_FOUND', message: `Function '${fnName}' not found` },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
// Reject private functions from RPC dispatch
if (entry.private) {
return {
status: 403,
body: { error: true, code: 'FORBIDDEN', message: 'Function is not client-callable' },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
try {
const argValues = entry.params.map(p => args[p.name])
const result = await entry.fn(...argValues)
// View path — return Response directly with invalidation header
if (result instanceof Response) {
const invalidate = resolveInvalidation(entry, args)
if (invalidate) {
result.headers.set('X-Mizan-Invalidate', formatInvalidateHeader(invalidate))
}
result.headers.set('Cache-Control', 'no-store')
return {
status: result.status,
body: result,
headers: Object.fromEntries(result.headers.entries()),
}
}
// RPC path — JSON response with invalidation
const invalidate = resolveInvalidation(entry, args)
const responseData: Record<string, any> = { result }
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
}
if (invalidate) {
responseData.invalidate = invalidate
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(invalidate)
// Purge origin-side cache
const cb = getCache()
if (cb) {
try {
for (const entry of invalidate) {
if (typeof entry === 'string') {
cachePurge(cb, entry)
} else {
cachePurge(cb, entry.context, entry.params, _cacheSecret)
}
}
} catch { /* purge failure is non-fatal */ }
}
}
return { status: 200, body: responseData, headers }
} catch (e: any) {
return {
status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
}

View File

@@ -0,0 +1,17 @@
export { ReactContext } from './types'
export type { ClientOptions, EdgeManifest, RegistryEntry } from './types'
export { client } from './decorator'
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
export { handleContextFetch, handleMutationCall } from './dispatch'
export type { MizanResponse } from './dispatch'
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
export { generateManifest } from './manifest'
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
export type { CacheBackend } from './cache'
export { setCacheSecret } from './dispatch'

View File

@@ -0,0 +1,102 @@
/**
* Invalidation protocol — header formatting, auto-scoping.
*
* Matches Django's implementation exactly. Same format. Same rules.
*/
import type { RegistryEntry } from './types'
import { getContextGroups, getContextParamNames, getFunction } from './registry'
type InvalidateEntry = string | { context: string; params: Record<string, any> }
/**
* Resolve invalidation targets with three-tier auto-scoping.
*
* Tier 1: Argument name matching
* Tier 2: Auth inference (Edge-side, not handled here)
* Tier 3: Broad fallback
*/
export function resolveInvalidation(
entry: RegistryEntry,
callArgs: Record<string, any> | null,
): InvalidateEntry[] | null {
if (!entry.affects) return null
const result: InvalidateEntry[] = []
const seen = new Set<string>()
for (const target of entry.affects) {
const targetName = target.name
if (seen.has(targetName)) continue
seen.add(targetName)
// Resolve which context the target belongs to (for param lookup)
const resolved = resolveAffectsTarget(targetName)
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
// Tier 1: argument name matching
if (callArgs && ctxForParams) {
const contextParams = getContextParamNames(ctxForParams)
const matched: Record<string, any> = {}
for (const [k, v] of Object.entries(callArgs)) {
if (contextParams.has(k)) matched[k] = v
}
if (Object.keys(matched).length > 0) {
result.push({ context: targetName, params: matched })
continue
}
}
// Tier 3: broad fallback
result.push(targetName)
}
return result.length > 0 ? result : null
}
/**
* Determine whether an affects target is a context name or function name.
*/
function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
const groups = getContextGroups()
if (name in groups) {
return { type: 'context', name }
}
for (const [ctxName, fnNames] of Object.entries(groups)) {
if (fnNames.includes(name)) {
return { type: 'function', name, context: ctxName }
}
}
return { type: 'context', name }
}
/**
* Format invalidation targets as X-Mizan-Invalidate header value.
*
* Format: comma-separated contexts. Semicolon-separated URL-encoded params.
*/
export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
const parts: string[] = []
for (const entry of invalidate) {
if (typeof entry === 'string') {
parts.push(entry)
} else {
const { context, params } = entry
if (params && Object.keys(params).length > 0) {
const paramStr = Object.entries(params)
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
.map(([k, v]) => `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`)
.join(';')
parts.push(`${context};${paramStr}`)
} else {
parts.push(context)
}
}
}
return parts.join(', ')
}

View File

@@ -0,0 +1,93 @@
/**
* Edge Manifest Generator
*
* Produces the same JSON format as mizan-django. One Edge Worker.
* Two backend languages. Same manifest.
*/
import type { EdgeManifest } from './types'
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
// Both camelCase and snake_case forms included for cross-language matching.
// Wire format is snake_case (protocol rule); camelCase is the TS-local convention.
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
const groups = getContextGroups()
const allFunctions = getAllFunctions()
const manifest: EdgeManifest = { version: 1, contexts: {}, mutations: {} }
// Contexts
for (const [ctxName, fnNames] of Object.entries(groups)) {
const paramNames = new Set<string>()
const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = []
const pageRoutes: string[] = []
for (const fnName of fnNames) {
const entry = allFunctions.get(fnName)
if (!entry) continue
for (const p of entry.params) paramNames.add(p.name)
const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
if (entry.route) {
fnEntry.route = entry.route
fnEntry.methods = entry.methods || ['GET']
pageRoutes.push(entry.route)
}
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
functions.push(fnEntry)
}
const sortedParams = [...paramNames].sort()
const userScoped = [...paramNames].some(p => USER_SCOPED_PARAMS.has(p))
const ctxEntry: EdgeManifest['contexts'][string] = {
functions,
endpoints: [`${baseUrl}/ctx/${ctxName}/`],
params: sortedParams,
user_scoped: userScoped,
render_strategy: userScoped ? 'dynamic_cached' : 'psr',
}
if (pageRoutes.length > 0) {
ctxEntry.page_routes = pageRoutes
}
manifest.contexts[ctxName] = ctxEntry
}
// Mutations
for (const [fnName, entry] of allFunctions) {
if (!entry.affects) continue
const affectedContexts = [...new Set(entry.affects.map(a => a.name))]
// Auto-scoped params
const fnParamNames = new Set(entry.params.map(p => p.name))
const autoScoped: string[] = []
for (const ctxName of affectedContexts) {
const ctxParams = getContextParamNames(ctxName)
for (const p of fnParamNames) {
if (ctxParams.has(p) && !autoScoped.includes(p)) {
autoScoped.push(p)
}
}
}
const mutation: EdgeManifest['mutations'][string] = {
affects: affectedContexts,
}
if (autoScoped.length > 0) mutation.auto_scoped_params = autoScoped.sort()
if (entry.private) mutation.private = true
if (entry.route) {
mutation.route = entry.route
mutation.methods = entry.methods || ['POST']
}
manifest.mutations[fnName] = mutation
}
return manifest
}

View File

@@ -0,0 +1,49 @@
/**
* Mizan Registry — Central registration for server functions.
*/
import type { RegistryEntry } from './types'
const _functions: Map<string, RegistryEntry> = new Map()
export function register(entry: RegistryEntry): void {
if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
throw new Error(`Function '${entry.name}' already registered`)
}
_functions.set(entry.name, entry)
}
export function getFunction(name: string): RegistryEntry | undefined {
return _functions.get(name)
}
export function getAllFunctions(): Map<string, RegistryEntry> {
return new Map(_functions)
}
export function getContextGroups(): Record<string, string[]> {
const groups: Record<string, string[]> = {}
for (const [name, entry] of _functions) {
if (entry.context) {
if (!groups[entry.context]) groups[entry.context] = []
groups[entry.context].push(name)
}
}
return groups
}
export function getContextParamNames(contextName: string): Set<string> {
const params = new Set<string>()
for (const [, entry] of _functions) {
if (entry.context === contextName) {
for (const p of entry.params) {
params.add(p.name)
}
}
}
return params
}
export function clearRegistry(): void {
_functions.clear()
}

View File

@@ -0,0 +1,66 @@
/**
* Mizan TypeScript Adapter — Shared Types
*/
export class ReactContext {
constructor(public readonly name: string) {
if (!name) throw new Error('ReactContext name must be non-empty')
}
}
export type AffectsTarget = ReactContext | string
export interface ClientOptions {
context?: ReactContext | string
affects?: AffectsTarget | AffectsTarget[]
private?: boolean
route?: string
methods?: string[]
auth?: boolean
rev?: number
cache?: number | false
}
export interface ParamDef {
name: string
type: string
required: boolean
}
export interface RegistryEntry {
name: string
fn: (...args: any[]) => Promise<any>
context?: string
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
params: ParamDef[]
private: boolean
viewPath: boolean
route?: string
methods?: string[]
auth?: boolean
rev?: number
cache?: number | false
}
export interface ManifestContext {
functions: Array<{ name: string; path: 'rpc' | 'view' }>
endpoints: string[]
params: string[]
user_scoped: boolean
render_strategy: 'psr' | 'dynamic_cached'
page_routes?: string[]
}
export interface ManifestMutation {
affects: string[]
auto_scoped_params?: string[]
private?: boolean
route?: string
methods?: string[]
}
export interface EdgeManifest {
version: number
contexts: Record<string, ManifestContext>
mutations: Record<string, ManifestMutation>
}

View File

@@ -0,0 +1,425 @@
/**
* Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly.
*
* These prove that a Cloudflare Worker (Edge) can sit in front of a
* TypeScript backend and behave identically to sitting in front of Django.
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
const UserCtx = new ReactContext('user')
function setupUserContext() {
const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) {
return { name: `user_${userId}`, email: `user${userId}@test.com` }
})
const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) {
return { count: userId * 10 }
})
const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
return { name, email: `user${userId}@test.com` }
})
client({ affects: 'userProfile' }, async function updateName(userId: number, name: string) {
return { name, email: `user${userId}@test.com` }
})
}
describe('Edge Compatibility', () => {
beforeEach(() => {
clearRegistry()
setupUserContext()
})
// ── Deterministic JSON ──────────────────────────────────────────────
test('deterministic JSON output', async () => {
const r1 = await handleContextFetch('user', { userId: '5' })
const r2 = await handleContextFetch('user', { userId: '5' })
expect(JSON.stringify(r1.body)).toBe(JSON.stringify(r2.body))
})
test('different params produce different responses', async () => {
const r1 = await handleContextFetch('user', { userId: '5' })
const r2 = await handleContextFetch('user', { userId: '6' })
expect(JSON.stringify(r1.body)).not.toBe(JSON.stringify(r2.body))
expect(r1.body.userProfile.name).toBe('user_5')
expect(r2.body.userProfile.name).toBe('user_6')
})
// ── Cache-Control correctness ───────────────────────────────────────
test('context GET emits no-store', async () => {
const r = await handleContextFetch('user', { userId: '5' })
expect(r.headers['Cache-Control']).toBe('no-store')
})
test('mutation POST not cacheable', async () => {
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
expect(r.headers['Cache-Control']).toBe('no-store')
})
test('error response not cacheable', async () => {
const r = await handleContextFetch('nonexistent', {})
expect(r.status).toBe(404)
expect(r.headers['Cache-Control']).toBe('no-store')
})
// ── X-Mizan-Invalidate header ──────────────────────────────────────
test('mutation response includes invalidation header', async () => {
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
expect(r.headers['X-Mizan-Invalidate']).toBeDefined()
expect(r.headers['X-Mizan-Invalidate']).toContain('user')
})
test('auto-scoped invalidation in header', async () => {
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
expect(r.headers['X-Mizan-Invalidate']).toBe('user;userId=5')
})
test('invalidation header matches JSON body', async () => {
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
const body = r.body
expect(body.invalidate[0].context).toBe('user')
expect(body.invalidate[0].params.userId).toBe(5)
expect(r.headers['X-Mizan-Invalidate']).toContain('user;userId=5')
})
test('function-level invalidation in header', async () => {
const r = await handleMutationCall('updateName', { userId: 7, name: 'X' })
expect(r.headers['X-Mizan-Invalidate']).toContain('userProfile')
})
test('no invalidation header on context GET', async () => {
const r = await handleContextFetch('user', { userId: '5' })
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
})
// ── Header format edge cases ───────────────────────────────────────
test('special characters in param values are URL-encoded', () => {
const header = formatInvalidateHeader([
{ context: 'search', params: { q: 'a;b' } },
])
expect(header).not.toContain(';b')
expect(header).toContain('a%3Bb')
})
test('spaces in param values are URL-encoded', () => {
const header = formatInvalidateHeader([
{ context: 'search', params: { q: 'hello world' } },
])
expect(header).not.toContain(' ')
expect(header).toContain('hello%20world')
})
test('header round-trip with special chars', () => {
const header = formatInvalidateHeader([
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
])
// Parse (what Edge does)
const segments = header.split(';')
const ctx = segments[0]
const params: Record<string, string> = {}
for (const seg of segments.slice(1)) {
const [k, v] = seg.split('=', 2)
params[decodeURIComponent(k)] = decodeURIComponent(v)
}
expect(ctx).toBe('data')
expect(params.name).toBe("O'Brien")
expect(params.tag).toBe('a;b;c')
})
// ── Empty invalidation ─────────────────────────────────────────────
test('no affects = no header, no body key', async () => {
client({ context: new ReactContext('plain') }, async function plainFn() {
return { ok: true }
})
// A context function called via mutation dispatch (shouldn't have invalidation)
// Actually test a function without affects
clearRegistry()
client({}, async function noAffects() { return { ok: true } })
const r = await handleMutationCall('noAffects', {})
expect(r.body.invalidate).toBeUndefined()
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
})
// ── Private functions ──────────────────────────────────────────────
test('private functions rejected from RPC', async () => {
clearRegistry()
client({ affects: 'subscription', private: true }, async function webhook() {
return { ok: true }
})
const r = await handleMutationCall('webhook', {})
expect(r.status).toBe(403)
})
// ── Unknown function ───────────────────────────────────────────────
test('unknown function returns 404', async () => {
const r = await handleMutationCall('doesNotExist', {})
expect(r.status).toBe(404)
})
test('unknown context returns 404', async () => {
const r = await handleContextFetch('doesNotExist', {})
expect(r.status).toBe(404)
})
})
describe('Manifest', () => {
beforeEach(() => {
clearRegistry()
setupUserContext()
})
test('manifest matches expected structure', () => {
const m = generateManifest()
expect(m.version).toBe(1)
expect(m.contexts.user).toBeDefined()
expect(m.contexts.user.endpoints).toEqual(['/api/mizan/ctx/user/'])
expect(m.contexts.user.params).toContain('userId')
expect(m.contexts.user.user_scoped).toBe(true)
expect(m.contexts.user.render_strategy).toBe('dynamic_cached')
})
test('mutations section includes auto-scoped params', () => {
const m = generateManifest()
expect(m.mutations.updateProfile).toBeDefined()
expect(m.mutations.updateProfile.affects).toEqual(['user'])
expect(m.mutations.updateProfile.auto_scoped_params).toContain('userId')
})
test('PSR strategy for non-user-scoped context', () => {
clearRegistry()
const ProductCtx = new ReactContext('products')
client({ context: ProductCtx }, async function productDetail(productId: number) {
return { id: productId }
})
const m = generateManifest()
expect(m.contexts.products.user_scoped).toBe(false)
expect(m.contexts.products.render_strategy).toBe('psr')
})
test('private mutation in manifest', () => {
clearRegistry()
client(
{ affects: 'subscription', private: true, route: '/webhooks/stripe/', methods: ['POST'] },
async function stripeWebhook() { return new Response('ok') },
)
const m = generateManifest()
expect(m.mutations.stripeWebhook).toBeDefined()
expect(m.mutations.stripeWebhook.private).toBe(true)
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
})
test('rev appears in manifest', () => {
clearRegistry()
const Ctx = new ReactContext('data')
client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) {
return { value: itemId }
})
const m = generateManifest()
const fn = m.contexts.data.functions[0]
expect(fn.rev).toBe(3)
})
test('cache TTL appears in manifest', () => {
clearRegistry()
const Ctx = new ReactContext('trending')
client({ context: Ctx, cache: 60 }, async function trendingFn() {
return { items: [] }
})
const m = generateManifest()
const fn = m.contexts.trending.functions[0]
expect(fn.cache).toBe(60)
})
test('cache=60 still emits no-store on HTTP', async () => {
clearRegistry()
const Ctx = new ReactContext('live')
client({ context: Ctx, cache: 60 }, async function liveFn() {
return { score: 42 }
})
const r = await handleContextFetch('live', {})
expect(r.headers['Cache-Control']).toBe('no-store')
})
test('cache=false sets no-store', async () => {
clearRegistry()
const Ctx = new ReactContext('random')
client({ context: Ctx, cache: false }, async function randomFn() {
return { value: Math.random() }
})
const r = await handleContextFetch('random', {})
expect(r.headers['Cache-Control']).toBe('no-store')
})
})
// ── Cache Conformance Tests ────────────────────────────────────────────
describe('Cache Conformance', () => {
const SECRET = 'test-pin-secret-that-is-32bytes!'
test('deriveCacheKey determinism', () => {
const k1 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
const k2 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
expect(k1).toBe(k2)
expect(k1).toStartWith('ctx:user:')
expect(k1).toHaveLength('ctx:user:'.length + 64)
})
test('deriveCacheKey param order irrelevant', () => {
const k1 = deriveCacheKey(SECRET, 'ctx', { a: '1', b: '2' })
const k2 = deriveCacheKey(SECRET, 'ctx', { b: '2', a: '1' })
expect(k1).toBe(k2)
})
test('deriveCacheKey cross-language pin (matches Python)', () => {
// These exact values are pinned from Python's derive_cache_key output.
// If this test fails, cross-language cache key compatibility is broken.
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
const userScopedKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, '5', 0)
expect(userScopedKey).toBe('ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
})
test('MemoryCache get/set/clear', () => {
const cache = new MemoryCache()
expect(cache.get('k1')).toBeNull()
cache.set('k1', '{"data":true}')
expect(cache.get('k1')).toBe('{"data":true}')
cache.clear()
expect(cache.get('k1')).toBeNull()
})
test('scoped purge recomputes key directly', () => {
const cache = new MemoryCache()
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
const count = cachePurge(cache, 'user', { user_id: '5' }, SECRET)
expect(count).toBe(1)
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).not.toBeNull()
})
test('broad purge removes all entries', () => {
const cache = new MemoryCache()
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
const count = cachePurge(cache, 'user')
expect(count).toBe(2)
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).toBeNull()
})
test('handleContextFetch caches response', async () => {
clearRegistry()
const Ctx = new ReactContext('cached')
client({ context: Ctx }, async function cachedFn(itemId: number) {
return { value: itemId }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
const r1 = await handleContextFetch('cached', { itemId: '1' })
expect(r1.status).toBe(200)
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
const r2 = await handleContextFetch('cached', { itemId: '1' })
expect(r2.status).toBe(200)
expect(r2.headers['X-Mizan-Cache']).toBe('HIT')
expect(r2.body).toEqual(r1.body)
resetCache()
setCacheSecret(null)
})
test('handleMutationCall purges cache', async () => {
clearRegistry()
const Ctx = new ReactContext('product')
client({ context: Ctx }, async function getProduct(productId: number) {
return { id: productId }
})
client({ affects: Ctx }, async function updateProduct(productId: number, name: string) {
return { ok: true }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
// Prime cache
await handleContextFetch('product', { productId: '1' })
// Mutate
await handleMutationCall('updateProduct', { productId: 1, name: 'New' })
// Cache should be purged — next fetch is MISS
const r = await handleContextFetch('product', { productId: '1' })
expect(r.headers['X-Mizan-Cache']).toBe('MISS')
resetCache()
setCacheSecret(null)
})
test('scoped invalidation preserves other entries', async () => {
clearRegistry()
const Ctx = new ReactContext('user')
client({ context: Ctx }, async function userProfile(userId: number) {
return { name: `user_${userId}` }
})
client({ affects: Ctx }, async function editUser(userId: number, name: string) {
return { ok: true }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
// Prime both users
await handleContextFetch('user', { userId: '5' })
await handleContextFetch('user', { userId: '6' })
// Mutate only user 5
await handleMutationCall('editUser', { userId: 5, name: 'New' })
// User 6 should still be cached
const r6 = await handleContextFetch('user', { userId: '6' })
expect(r6.headers['X-Mizan-Cache']).toBe('HIT')
// User 5 should be a miss
const r5 = await handleContextFetch('user', { userId: '5' })
expect(r5.headers['X-Mizan-Cache']).toBe('MISS')
resetCache()
setCacheSecret(null)
})
})

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"types": ["bun-types"]
},
"include": ["src/**/*", "tests/**/*"]
}

View File

@@ -0,0 +1,27 @@
[project]
name = "mizan-core"
version = "0.1.0"
license = "Elastic-2.0"
description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter."
requires-python = ">=3.10"
dependencies = [
"PyJWT>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mizan_core"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
python_classes = ["*Tests", "*Test", "Test*"]
python_functions = ["test_*"]

View File

@@ -0,0 +1,115 @@
"""
Cache backends — MemoryCache (testing) and RedisCache (production).
Simple key-value stores. No reverse indexes. Cache keys are derived
from HMAC, so scoped purge just recomputes the key and deletes it.
Broad purge uses key-prefix scan (rare operation).
"""
from __future__ import annotations
from typing import Protocol
class CacheBackend(Protocol):
"""Interface that all Mizan cache backends implement."""
def get(self, key: str) -> bytes | None: ...
def set(self, key: str, value: bytes) -> None: ...
def delete(self, key: str) -> bool: ...
def delete_by_prefix(self, prefix: str) -> int: ...
def clear(self) -> None: ...
class MemoryCache:
"""
In-memory cache backend for testing.
Uses a Python dict. No persistence, no cross-process sharing.
"""
def __init__(self) -> None:
self._store: dict[str, bytes] = {}
def get(self, key: str) -> bytes | None:
return self._store.get(key)
def set(self, key: str, value: bytes) -> None:
self._store[key] = value
def delete(self, key: str) -> bool:
if key in self._store:
del self._store[key]
return True
return False
def delete_by_prefix(self, prefix: str) -> int:
to_delete = [k for k in self._store if k.startswith(prefix)]
for k in to_delete:
del self._store[k]
return len(to_delete)
def clear(self) -> None:
self._store.clear()
class RedisCache:
"""
Redis-backed cache backend for production.
Simple GET/SET/DEL. No reverse indexes. Scoped purge recomputes
the HMAC key and deletes directly. Broad purge uses SCAN.
"""
DEFAULT_TTL = 86400 # 24h safety-net
def __init__(
self,
redis_url: str,
prefix: str = "mizan:",
ttl: int | None = None,
) -> None:
try:
import redis as redis_lib
except ImportError:
raise ImportError(
"Redis is required for Mizan's cache backend. "
"Install it with: pip install mizan[cache]"
)
self._client = redis_lib.from_url(
redis_url,
socket_connect_timeout=5,
socket_timeout=5,
health_check_interval=30,
retry_on_timeout=True,
max_connections=50,
)
self._prefix = prefix
self._ttl = ttl if ttl is not None else self.DEFAULT_TTL
def _key(self, key: str) -> str:
return f"{self._prefix}{key}"
def get(self, key: str) -> bytes | None:
return self._client.get(self._key(key))
def set(self, key: str, value: bytes) -> None:
self._client.set(self._key(key), value, ex=self._ttl)
def delete(self, key: str) -> bool:
return self._client.unlink(self._key(key)) > 0
def delete_by_prefix(self, prefix: str) -> int:
pattern = f"{self._prefix}{prefix}*"
count = 0
cursor = 0
while True:
cursor, keys = self._client.scan(cursor, match=pattern, count=1000)
if keys:
count += self._client.unlink(*keys)
if cursor == 0:
break
return count
def clear(self) -> None:
self.delete_by_prefix("")

Some files were not shown because too many files have changed in this diff Show More