diff --git a/packages/mizan-django/src/mizan/cache/keys.py b/packages/mizan-django/src/mizan/cache/keys.py index ce7632c..c163556 100644 --- a/packages/mizan-django/src/mizan/cache/keys.py +++ b/packages/mizan-django/src/mizan/cache/keys.py @@ -32,7 +32,18 @@ def derive_cache_key( Returns a prefixed key: "ctx:{context}:{hmac_hex}" so that broad purge can SCAN by prefix "ctx:{context}:*". """ - sorted_params = {k: str(v) for k, v in sorted(params.items())} + def _normalize(v: Any) -> str: + """Normalize values for cross-language HMAC consistency. + Python str(True)="True" but JS String(true)="true". Use JSON-native forms.""" + if v is True: + return "true" + if v is False: + return "false" + if v is None: + return "null" + return str(v) + + sorted_params = {k: _normalize(v) for k, v in sorted(params.items())} key_data: dict[str, Any] = {"c": context, "p": sorted_params, "r": rev} if user_id is not None: diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py index b4a30ea..f5bd72f 100644 --- a/packages/mizan-django/src/mizan/client/executor.py +++ b/packages/mizan-django/src/mizan/client/executor.py @@ -23,7 +23,7 @@ from enum import Enum from functools import wraps from typing import TYPE_CHECKING, Any, Callable -from django.http import HttpRequest, HttpResponse, JsonResponse +from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse from django.views.decorators.csrf import csrf_protect from pydantic import BaseModel, ValidationError @@ -346,7 +346,7 @@ def execute_function( request: HttpRequest, fn_name: str, input_data: dict[str, Any] | None = None, -) -> FunctionResult | FunctionError: +) -> "FunctionResult | FunctionError | HttpResponseBase": """ Execute a registered server function. diff --git a/packages/mizan-react/src/context.tsx b/packages/mizan-react/src/context.tsx index af19cb6..769358a 100644 --- a/packages/mizan-react/src/context.tsx +++ b/packages/mizan-react/src/context.tsx @@ -367,11 +367,12 @@ export function MizanProvider({ [contextStore] ) - // Refresh a specific context + // Refresh a specific context via GET /ctx// const refreshContext = useCallback( async (name: string): Promise => { try { - const data = await call(name, {}) + const response = await httpClient.request('GET', `${baseUrl}/ctx/${name}/`) + const data = await response.json() setContextStore(prev => { const next = { ...prev, [name]: data } // Notify listeners diff --git a/packages/mizan-react/src/forms.ts b/packages/mizan-react/src/forms.ts index 6af940a..ba22a17 100644 --- a/packages/mizan-react/src/forms.ts +++ b/packages/mizan-react/src/forms.ts @@ -811,8 +811,16 @@ export function useMizanFormCore>( }, [errors, touchedFields]) const isValid = useMemo(() => { - return errors !== null && !hasErrors - }, [errors, hasErrors]) + if (errors === null || hasErrors) return false + // Also check that all required fields have been touched + if (schema) { + for (const fieldName of schema.fieldOrder) { + const field = schema.fields[fieldName] + if (field.required && !touchedFields.has(fieldName)) return false + } + } + return true + }, [errors, hasErrors, schema, touchedFields]) return { data, diff --git a/packages/mizan-runtime/src/index.ts b/packages/mizan-runtime/src/index.ts index 24e877e..8d27eb4 100644 --- a/packages/mizan-runtime/src/index.ts +++ b/packages/mizan-runtime/src/index.ts @@ -151,6 +151,24 @@ function flush(): void { // === Fetch === +async function fetchWithRetry( + input: RequestInfo | URL, + init?: RequestInit, + retries = 2, +): Promise { + for (let attempt = 0; ; attempt++) { + try { + const res = await fetch(input, init) + // Don't retry client errors (4xx) — only server/network errors + if (res.ok || (res.status >= 400 && res.status < 500)) return res + if (attempt >= retries) return res + } catch (e) { + if (attempt >= retries) throw e + } + await new Promise(r => setTimeout(r, (attempt + 1) * 200)) + } +} + async function resolveHeaders(): Promise> { await initSession() @@ -179,7 +197,7 @@ export async function mizanFetch( } const headers = await resolveHeaders() - const res = await fetch(url.toString(), { headers, credentials: 'same-origin' }) + const res = await fetchWithRetry(url.toString(), { headers, credentials: 'same-origin' }) if (!res.ok) throw new MizanError(res.status, await res.text()) return res.json() } @@ -191,6 +209,7 @@ export async function mizanCall( const headers = await resolveHeaders() headers['Content-Type'] = 'application/json' + // Mutations are not retried — they are not idempotent const res = await fetch(`${config.baseUrl}/call/`, { method: 'POST', headers,